mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
fix: resolve all 14 ESLint warnings across the codebase
- Add varsIgnorePattern to no-unused-vars for destructuring patterns - Fix all react-hooks/exhaustive-deps by adding missing dependencies - Refactor useAnimatedNumber to use ref instead of stale state closure - Wrap handleLinkResult in useCallback for stable deps - Extract OAuth utilities from OAuthCallback.tsx to utils/oauth.ts - Extract background config utilities to utils/backgroundConfig.ts - Remove unused catch parameter in GiftSubscription
This commit is contained in:
@@ -30,7 +30,10 @@ export default tseslint.config(
|
|||||||
'react-hooks/variables': 'off',
|
'react-hooks/variables': 'off',
|
||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
'no-empty': ['warn', { allowEmptyCatch: true }],
|
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||||
'no-eval': 'error',
|
'no-eval': 'error',
|
||||||
'no-implied-eval': 'error',
|
'no-implied-eval': 'error',
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
|||||||
setScriptLoaded(true);
|
setScriptLoaded(true);
|
||||||
initTelegramLogin();
|
initTelegramLogin();
|
||||||
}
|
}
|
||||||
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
|
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access, t]);
|
||||||
|
|
||||||
// Legacy widget effect (only when NOT OIDC)
|
// Legacy widget effect (only when NOT OIDC)
|
||||||
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
|
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { brandingApi } from '@/api/branding';
|
import { brandingApi } from '@/api/branding';
|
||||||
import { setCachedAnimationConfig } from '@/components/backgrounds/BackgroundRenderer';
|
import { setCachedAnimationConfig } from '@/utils/backgroundConfig';
|
||||||
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
|
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
|
||||||
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||||
import { BackgroundConfigEditor } from './BackgroundConfigEditor';
|
import { BackgroundConfigEditor } from './BackgroundConfigEditor';
|
||||||
|
|||||||
@@ -5,57 +5,7 @@ import { brandingApi } from '@/api/branding';
|
|||||||
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
||||||
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||||
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||||
|
import { validateConfig, getCachedConfig, setCachedConfig } from '@/utils/backgroundConfig';
|
||||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
|
||||||
const MAX_CONFIG_JSON_LENGTH = 10_000;
|
|
||||||
|
|
||||||
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
|
||||||
'aurora',
|
|
||||||
'sparkles',
|
|
||||||
'vortex',
|
|
||||||
'shooting-stars',
|
|
||||||
'background-beams',
|
|
||||||
'background-beams-collision',
|
|
||||||
'gradient-animation',
|
|
||||||
'wavy',
|
|
||||||
'background-lines',
|
|
||||||
'boxes',
|
|
||||||
'meteors',
|
|
||||||
'grid',
|
|
||||||
'dots',
|
|
||||||
'spotlight',
|
|
||||||
'ripple',
|
|
||||||
'none',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function validateConfig(parsed: unknown): AnimationConfig | null {
|
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
|
||||||
if (typeof obj.enabled !== 'boolean') return null;
|
|
||||||
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
|
|
||||||
if (typeof obj.opacity !== 'number') return null;
|
|
||||||
if (typeof obj.blur !== 'number') return null;
|
|
||||||
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
|
|
||||||
return null;
|
|
||||||
return {
|
|
||||||
enabled: obj.enabled,
|
|
||||||
type: obj.type as BackgroundType,
|
|
||||||
opacity: Math.max(0, Math.min(1, obj.opacity)),
|
|
||||||
blur: Math.max(0, Math.min(50, obj.blur)),
|
|
||||||
settings: (obj.settings as Record<string, unknown>) ?? {},
|
|
||||||
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedConfig(): AnimationConfig | null {
|
|
||||||
try {
|
|
||||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
|
||||||
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
|
|
||||||
return validateConfig(JSON.parse(cached));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefetch the background JS chunk immediately based on localStorage cache.
|
// Prefetch the background JS chunk immediately based on localStorage cache.
|
||||||
const cachedConfig = getCachedConfig();
|
const cachedConfig = getCachedConfig();
|
||||||
@@ -63,17 +13,6 @@ if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none')
|
|||||||
prefetchBackground(cachedConfig.type);
|
prefetchBackground(cachedConfig.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedConfig(config: AnimationConfig) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setCachedAnimationConfig(config: AnimationConfig) {
|
|
||||||
const validated = validateConfig(config);
|
|
||||||
if (validated) setCachedConfig(validated);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||||
const reduced = { ...settings };
|
const reduced = { ...settings };
|
||||||
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
||||||
@@ -94,7 +33,6 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
|
|||||||
return reduced;
|
return reduced;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the background animation into a portal at document.body */
|
|
||||||
function RenderBackground({ config }: { config: AnimationConfig }) {
|
function RenderBackground({ config }: { config: AnimationConfig }) {
|
||||||
const prefersReducedMotion = useMemo(
|
const prefersReducedMotion = useMemo(
|
||||||
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
@@ -136,7 +74,6 @@ function RenderBackground({ config }: { config: AnimationConfig }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BackgroundRenderer that fetches config from the branding API (for authenticated app shell) */
|
|
||||||
export function BackgroundRenderer() {
|
export function BackgroundRenderer() {
|
||||||
const { data: config } = useQuery({
|
const { data: config } = useQuery({
|
||||||
queryKey: ['animation-config'],
|
queryKey: ['animation-config'],
|
||||||
@@ -155,7 +92,6 @@ export function BackgroundRenderer() {
|
|||||||
return <RenderBackground config={effectiveConfig} />;
|
return <RenderBackground config={effectiveConfig} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** StaticBackgroundRenderer that uses a provided config (for public landing pages) */
|
|
||||||
export function StaticBackgroundRenderer({ config }: { config: AnimationConfig }) {
|
export function StaticBackgroundRenderer({ config }: { config: AnimationConfig }) {
|
||||||
const validated = useMemo(() => validateConfig(config), [config]);
|
const validated = useMemo(() => validateConfig(config), [config]);
|
||||||
if (!validated) return null;
|
if (!validated) return null;
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function CollisionMechanism({
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [collision.detected]);
|
}, [collision.detected, collision.coordinates]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,20 +6,19 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
*/
|
*/
|
||||||
export function useAnimatedNumber(target: number, duration = 1200): number {
|
export function useAnimatedNumber(target: number, duration = 1200): number {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
const ref = useRef({ start: 0, startTime: 0, target: 0 });
|
const currentRef = useRef(0);
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ref.current.start = value;
|
const start = currentRef.current;
|
||||||
ref.current.target = target;
|
const startTime = performance.now();
|
||||||
ref.current.startTime = performance.now();
|
|
||||||
|
|
||||||
const animate = (now: number) => {
|
const animate = (now: number) => {
|
||||||
const elapsed = now - ref.current.startTime;
|
const elapsed = now - startTime;
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
// easeOutExpo
|
|
||||||
const ease = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
|
const ease = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
|
||||||
const current = ref.current.start + (ref.current.target - ref.current.start) * ease;
|
const current = start + (target - start) * ease;
|
||||||
|
currentRef.current = current;
|
||||||
setValue(current);
|
setValue(current);
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
rafRef.current = requestAnimationFrame(animate);
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
|||||||
@@ -144,8 +144,7 @@ function SortableTariffCard({
|
|||||||
: 'border-dark-700/50 opacity-60'
|
: 'border-dark-700/50 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Drag handle */}
|
|
||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
@@ -155,106 +154,113 @@ function SortableTariffCard({
|
|||||||
<GripIcon />
|
<GripIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
||||||
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
|
<div className="min-w-0 flex-1">
|
||||||
{tariff.is_daily ? (
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
|
||||||
{t('admin.tariffs.dailyType')}
|
{tariff.is_daily ? (
|
||||||
</span>
|
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||||
) : (
|
{t('admin.tariffs.dailyType')}
|
||||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
</span>
|
||||||
{t('admin.tariffs.periodType')}
|
) : (
|
||||||
</span>
|
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||||
)}
|
{t('admin.tariffs.periodType')}
|
||||||
{tariff.is_trial_available && (
|
</span>
|
||||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
)}
|
||||||
{t('admin.tariffs.trial')}
|
{tariff.is_trial_available && (
|
||||||
</span>
|
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||||
)}
|
{t('admin.tariffs.trial')}
|
||||||
{tariff.show_in_gift && (
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
)}
|
||||||
<svg
|
{tariff.show_in_gift && (
|
||||||
className="h-3 w-3"
|
<span className="inline-flex items-center gap-1 rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||||
fill="none"
|
<svg
|
||||||
viewBox="0 0 24 24"
|
className="h-3 w-3"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
strokeWidth={2}
|
viewBox="0 0 24 24"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path
|
strokeWidth={2}
|
||||||
strokeLinecap="round"
|
>
|
||||||
strokeLinejoin="round"
|
<path
|
||||||
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
strokeLinecap="round"
|
||||||
/>
|
strokeLinejoin="round"
|
||||||
</svg>
|
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||||
{t('admin.tariffs.giftBadge')}
|
/>
|
||||||
</span>
|
</svg>
|
||||||
)}
|
{t('admin.tariffs.giftBadge')}
|
||||||
{!tariff.is_active && (
|
</span>
|
||||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
)}
|
||||||
{t('admin.tariffs.inactive')}
|
{!tariff.is_active && (
|
||||||
</span>
|
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||||
)}
|
{t('admin.tariffs.inactive')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||||
|
{tariff.is_daily && tariff.daily_price_kopeks > 0 && (
|
||||||
|
<span className="text-warning-400">
|
||||||
|
{(tariff.daily_price_kopeks / 100).toFixed(2)}{' '}
|
||||||
|
{t('admin.tariffs.currencyPerDay')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{tariff.traffic_limit_gb === 0
|
||||||
|
? t('admin.tariffs.unlimited')
|
||||||
|
: `${tariff.traffic_limit_gb} GB`}
|
||||||
|
</span>
|
||||||
|
<span>{t('admin.tariffs.devices', { count: tariff.device_limit })}</span>
|
||||||
|
<span>{t('admin.tariffs.servers', { count: tariff.servers_count })}</span>
|
||||||
|
<span>
|
||||||
|
{t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 sm:flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`rounded-lg p-2 transition-colors ${
|
||||||
|
tariff.is_active
|
||||||
|
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||||
|
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
tariff.is_active ? t('admin.tariffs.deactivate') : t('admin.tariffs.activate')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tariff.is_active ? <CheckIcon /> : <XIcon />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onToggleTrial}
|
||||||
|
className={`rounded-lg p-2 transition-colors ${
|
||||||
|
tariff.is_trial_available
|
||||||
|
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
|
||||||
|
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
title={t('admin.tariffs.toggleTrial')}
|
||||||
|
>
|
||||||
|
<GiftIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
|
title={t('admin.tariffs.edit')}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400"
|
||||||
|
title={t('admin.tariffs.delete')}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
|
||||||
{tariff.is_daily && tariff.daily_price_kopeks > 0 && (
|
|
||||||
<span className="text-warning-400">
|
|
||||||
{(tariff.daily_price_kopeks / 100).toFixed(2)} {t('admin.tariffs.currencyPerDay')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{tariff.traffic_limit_gb === 0
|
|
||||||
? t('admin.tariffs.unlimited')
|
|
||||||
: `${tariff.traffic_limit_gb} GB`}
|
|
||||||
</span>
|
|
||||||
<span>{t('admin.tariffs.devices', { count: tariff.device_limit })}</span>
|
|
||||||
<span>{t('admin.tariffs.servers', { count: tariff.servers_count })}</span>
|
|
||||||
<span>{t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className={`rounded-lg p-2 transition-colors ${
|
|
||||||
tariff.is_active
|
|
||||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
|
||||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
|
||||||
}`}
|
|
||||||
title={tariff.is_active ? t('admin.tariffs.deactivate') : t('admin.tariffs.activate')}
|
|
||||||
>
|
|
||||||
{tariff.is_active ? <CheckIcon /> : <XIcon />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onToggleTrial}
|
|
||||||
className={`rounded-lg p-2 transition-colors ${
|
|
||||||
tariff.is_trial_available
|
|
||||||
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
|
|
||||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
|
||||||
}`}
|
|
||||||
title={t('admin.tariffs.toggleTrial')}
|
|
||||||
>
|
|
||||||
<GiftIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
|
||||||
title={t('admin.tariffs.edit')}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400"
|
|
||||||
title={t('admin.tariffs.delete')}
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -10,7 +10,7 @@ import { Card } from '@/components/data-display/Card';
|
|||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||||
import ProviderIcon from '../components/ProviderIcon';
|
import ProviderIcon from '../components/ProviderIcon';
|
||||||
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from './OAuthCallback';
|
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth';
|
||||||
import { getTelegramInitData } from '../hooks/useTelegramSDK';
|
import { getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||||
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
||||||
import type { LinkedProvider } from '../types';
|
import type { LinkedProvider } from '../types';
|
||||||
@@ -53,15 +53,17 @@ function TelegramLinkWidget() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Shared handler for link result
|
const handleLinkResult = useCallback(
|
||||||
const handleLinkResult = async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
|
async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
|
||||||
if (response.merge_required && response.merge_token) {
|
if (response.merge_required && response.merge_token) {
|
||||||
navigate(`/merge/${response.merge_token}`, { replace: true });
|
navigate(`/merge/${response.merge_token}`, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
||||||
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[navigate, queryClient, showToast, t],
|
||||||
|
);
|
||||||
|
|
||||||
// OIDC callback handler (ref pattern to avoid stale closures)
|
// OIDC callback handler (ref pattern to avoid stale closures)
|
||||||
const handleOIDCCallbackRef =
|
const handleOIDCCallbackRef =
|
||||||
@@ -129,7 +131,7 @@ function TelegramLinkWidget() {
|
|||||||
} else {
|
} else {
|
||||||
initTelegramLogin();
|
initTelegramLogin();
|
||||||
}
|
}
|
||||||
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
|
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access, showToast, t]);
|
||||||
|
|
||||||
// Legacy widget effect (only when NOT OIDC)
|
// Legacy widget effect (only when NOT OIDC)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,7 +185,7 @@ function TelegramLinkWidget() {
|
|||||||
container.removeChild(container.firstChild);
|
container.removeChild(container.firstChild);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
|
}, [isOIDC, botUsername, navigate, showToast, t, queryClient, handleLinkResult]);
|
||||||
|
|
||||||
if (!botUsername && !isOIDC) {
|
if (!botUsername && !isOIDC) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function Connection() {
|
|||||||
hideLink: appConfig?.hideLink ?? false,
|
hideLink: appConfig?.hideLink ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]);
|
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ function BuyTabContent({
|
|||||||
setSubmitError(t('gift.failedDesc'));
|
setSubmitError(t('gift.failedDesc'));
|
||||||
}
|
}
|
||||||
// 'cancelled' — user closed the invoice, do nothing
|
// 'cancelled' — user closed the invoice, do nothing
|
||||||
} catch (e) {
|
} catch {
|
||||||
setSubmitError(t('gift.failedDesc'));
|
setSubmitError(t('gift.failedDesc'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
|
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
|
||||||
import { getErrorDetail } from './OAuthCallback';
|
import { getErrorDetail } from '../utils/oauth';
|
||||||
|
|
||||||
export default function LinkTelegramCallback() {
|
export default function LinkTelegramCallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { closeMiniApp } from '@telegram-apps/sdk-react';
|
|||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||||
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
||||||
import { saveOAuthState } from './OAuthCallback';
|
import { saveOAuthState } from '../utils/oauth';
|
||||||
import { consumeReferralCode, getPendingReferralCode } from '../utils/referral';
|
import { consumeReferralCode, getPendingReferralCode } from '../utils/referral';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
|||||||
@@ -3,50 +3,17 @@ import { useNavigate, useSearchParams } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
|
import {
|
||||||
|
peekLinkOAuthState,
|
||||||
|
clearLinkOAuthState,
|
||||||
|
loadOAuthState,
|
||||||
|
clearOAuthState,
|
||||||
|
getErrorDetail,
|
||||||
|
} from '../utils/oauth';
|
||||||
import type { ServerCompleteResponse } from '../types';
|
import type { ServerCompleteResponse } from '../types';
|
||||||
|
|
||||||
// SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts)
|
|
||||||
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
|
|
||||||
export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider';
|
|
||||||
|
|
||||||
// SessionStorage helpers for OAuth LOGIN state
|
|
||||||
const OAUTH_STATE_KEY = 'oauth_state';
|
|
||||||
const OAUTH_PROVIDER_KEY = 'oauth_provider';
|
|
||||||
|
|
||||||
export function saveOAuthState(state: string, provider: string): void {
|
|
||||||
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
|
||||||
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read link OAuth state without clearing (cleared only after successful match). */
|
|
||||||
function peekLinkOAuthState(): { state: string; provider: string } | null {
|
|
||||||
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
|
||||||
const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY);
|
|
||||||
if (!state || !provider) return null;
|
|
||||||
return { state, provider };
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLinkOAuthState(): void {
|
|
||||||
sessionStorage.removeItem(LINK_OAUTH_STATE_KEY);
|
|
||||||
sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CallbackMode = 'login' | 'link-browser' | 'link-server';
|
type CallbackMode = 'login' | 'link-browser' | 'link-server';
|
||||||
|
|
||||||
export function getErrorDetail(err: unknown): string | null {
|
|
||||||
if (err && typeof err === 'object' && 'response' in err) {
|
|
||||||
const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
|
|
||||||
const detail = resp?.data?.detail;
|
|
||||||
if (typeof detail === 'string') return detail;
|
|
||||||
if (detail && typeof detail === 'object' && 'message' in detail) {
|
|
||||||
const msg = (detail as Record<string, unknown>).message;
|
|
||||||
if (typeof msg === 'string') return msg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OAuthCallback() {
|
export default function OAuthCallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -96,15 +63,12 @@ export default function OAuthCallback() {
|
|||||||
provider = linkSaved.provider;
|
provider = linkSaved.provider;
|
||||||
state = linkSaved.state;
|
state = linkSaved.state;
|
||||||
} else {
|
} else {
|
||||||
// Peek at login state first; only clear if it matches URL state
|
const loginSaved = loadOAuthState();
|
||||||
const loginState = sessionStorage.getItem(OAUTH_STATE_KEY);
|
if (loginSaved && loginSaved.state === urlState) {
|
||||||
const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
|
clearOAuthState();
|
||||||
if (loginState && loginProvider && loginState === urlState) {
|
|
||||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
|
||||||
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
|
|
||||||
mode = 'login';
|
mode = 'login';
|
||||||
provider = loginProvider;
|
provider = loginSaved.provider;
|
||||||
state = loginState;
|
state = loginSaved.state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
src/utils/backgroundConfig.ts
Normal file
63
src/utils/backgroundConfig.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
||||||
|
|
||||||
|
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
||||||
|
const MAX_CONFIG_JSON_LENGTH = 10_000;
|
||||||
|
|
||||||
|
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
||||||
|
'aurora',
|
||||||
|
'sparkles',
|
||||||
|
'vortex',
|
||||||
|
'shooting-stars',
|
||||||
|
'background-beams',
|
||||||
|
'background-beams-collision',
|
||||||
|
'gradient-animation',
|
||||||
|
'wavy',
|
||||||
|
'background-lines',
|
||||||
|
'boxes',
|
||||||
|
'meteors',
|
||||||
|
'grid',
|
||||||
|
'dots',
|
||||||
|
'spotlight',
|
||||||
|
'ripple',
|
||||||
|
'none',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function validateConfig(parsed: unknown): AnimationConfig | null {
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (typeof obj.enabled !== 'boolean') return null;
|
||||||
|
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
|
||||||
|
if (typeof obj.opacity !== 'number') return null;
|
||||||
|
if (typeof obj.blur !== 'number') return null;
|
||||||
|
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
enabled: obj.enabled,
|
||||||
|
type: obj.type as BackgroundType,
|
||||||
|
opacity: Math.max(0, Math.min(1, obj.opacity)),
|
||||||
|
blur: Math.max(0, Math.min(50, obj.blur)),
|
||||||
|
settings: (obj.settings as Record<string, unknown>) ?? {},
|
||||||
|
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedConfig(): AnimationConfig | null {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||||
|
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
|
||||||
|
return validateConfig(JSON.parse(cached));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedConfig(config: AnimationConfig) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedAnimationConfig(config: AnimationConfig) {
|
||||||
|
const validated = validateConfig(config);
|
||||||
|
if (validated) setCachedConfig(validated);
|
||||||
|
}
|
||||||
48
src/utils/oauth.ts
Normal file
48
src/utils/oauth.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
|
||||||
|
export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider';
|
||||||
|
|
||||||
|
const OAUTH_STATE_KEY = 'oauth_state';
|
||||||
|
const OAUTH_PROVIDER_KEY = 'oauth_provider';
|
||||||
|
|
||||||
|
export function saveOAuthState(state: string, provider: string): void {
|
||||||
|
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||||
|
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadOAuthState(): { state: string; provider: string } | null {
|
||||||
|
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||||
|
const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
|
||||||
|
if (!state || !provider) return null;
|
||||||
|
return { state, provider };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOAuthState(): void {
|
||||||
|
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||||
|
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peekLinkOAuthState(): { state: string; provider: string } | null {
|
||||||
|
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
||||||
|
const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY);
|
||||||
|
if (!state || !provider) return null;
|
||||||
|
return { state, provider };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLinkOAuthState(): void {
|
||||||
|
sessionStorage.removeItem(LINK_OAUTH_STATE_KEY);
|
||||||
|
sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorDetail(err: unknown): string | null {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
|
||||||
|
const detail = resp?.data?.detail;
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
if (detail && typeof detail === 'object' && 'message' in detail) {
|
||||||
|
const msg = (detail as Record<string, unknown>).message;
|
||||||
|
if (typeof msg === 'string') return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user