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:
c0mrade
2026-03-13 19:48:32 +03:00
parent 2dab25c5a0
commit 62188b8d2e
15 changed files with 262 additions and 241 deletions

View File

@@ -103,7 +103,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
setScriptLoaded(true);
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)
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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 { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { BackgroundConfigEditor } from './BackgroundConfigEditor';

View File

@@ -5,57 +5,7 @@ import { brandingApi } from '@/api/branding';
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
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;
}
}
import { validateConfig, getCachedConfig, setCachedConfig } from '@/utils/backgroundConfig';
// Prefetch the background JS chunk immediately based on localStorage cache.
const cachedConfig = getCachedConfig();
@@ -63,17 +13,6 @@ if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none')
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> {
const reduced = { ...settings };
// 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;
}
/** Renders the background animation into a portal at document.body */
function RenderBackground({ config }: { config: AnimationConfig }) {
const prefersReducedMotion = useMemo(
() => 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() {
const { data: config } = useQuery({
queryKey: ['animation-config'],
@@ -155,7 +92,6 @@ export function BackgroundRenderer() {
return <RenderBackground config={effectiveConfig} />;
}
/** StaticBackgroundRenderer that uses a provided config (for public landing pages) */
export function StaticBackgroundRenderer({ config }: { config: AnimationConfig }) {
const validated = useMemo(() => validateConfig(config), [config]);
if (!validated) return null;

View File

@@ -125,7 +125,7 @@ function CollisionMechanism({
}, 2000);
return () => clearTimeout(timer);
}, [collision.detected]);
}, [collision.detected, collision.coordinates]);
return (
<>

View File

@@ -6,20 +6,19 @@ import { useEffect, useRef, useState } from 'react';
*/
export function useAnimatedNumber(target: number, duration = 1200): number {
const [value, setValue] = useState(0);
const ref = useRef({ start: 0, startTime: 0, target: 0 });
const currentRef = useRef(0);
const rafRef = useRef<number>(0);
useEffect(() => {
ref.current.start = value;
ref.current.target = target;
ref.current.startTime = performance.now();
const start = currentRef.current;
const startTime = performance.now();
const animate = (now: number) => {
const elapsed = now - ref.current.startTime;
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeOutExpo
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);
if (progress < 1) {
rafRef.current = requestAnimationFrame(animate);

View File

@@ -144,8 +144,7 @@ function SortableTariffCard({
: 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start gap-3">
{/* Drag handle */}
<div className="flex gap-3">
<button
{...attributes}
{...listeners}
@@ -155,106 +154,113 @@ function SortableTariffCard({
<GripIcon />
</button>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
{tariff.is_daily ? (
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{t('admin.tariffs.dailyType')}
</span>
) : (
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{t('admin.tariffs.periodType')}
</span>
)}
{tariff.is_trial_available && (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.tariffs.trial')}
</span>
)}
{tariff.show_in_gift && (
<span className="inline-flex items-center gap-1 rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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"
/>
</svg>
{t('admin.tariffs.giftBadge')}
</span>
)}
{!tariff.is_active && (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.tariffs.inactive')}
</span>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
{tariff.is_daily ? (
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{t('admin.tariffs.dailyType')}
</span>
) : (
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{t('admin.tariffs.periodType')}
</span>
)}
{tariff.is_trial_available && (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.tariffs.trial')}
</span>
)}
{tariff.show_in_gift && (
<span className="inline-flex items-center gap-1 rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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"
/>
</svg>
{t('admin.tariffs.giftBadge')}
</span>
)}
{!tariff.is_active && (
<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 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>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
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 { staggerContainer, staggerItem } from '@/components/motion/transitions';
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 { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
import type { LinkedProvider } from '../types';
@@ -53,15 +53,17 @@ function TelegramLinkWidget() {
};
}, []);
// Shared handler for link result
const handleLinkResult = async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
if (response.merge_required && response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
};
const handleLinkResult = useCallback(
async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
if (response.merge_required && response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
},
[navigate, queryClient, showToast, t],
);
// OIDC callback handler (ref pattern to avoid stale closures)
const handleOIDCCallbackRef =
@@ -129,7 +131,7 @@ function TelegramLinkWidget() {
} else {
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)
useEffect(() => {
@@ -183,7 +185,7 @@ function TelegramLinkWidget() {
container.removeChild(container.firstChild);
}
};
}, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
}, [isOIDC, botUsername, navigate, showToast, t, queryClient, handleLinkResult]);
if (!botUsername && !isOIDC) {
return null;

View File

@@ -43,7 +43,7 @@ export default function Connection() {
hideLink: appConfig?.hideLink ?? false,
},
});
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]);
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {

View File

@@ -611,7 +611,7 @@ function BuyTabContent({
setSubmitError(t('gift.failedDesc'));
}
// 'cancelled' — user closed the invoice, do nothing
} catch (e) {
} catch {
setSubmitError(t('gift.failedDesc'));
}
return;

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
import { useToast } from '../components/Toast';
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
import { getErrorDetail } from './OAuthCallback';
import { getErrorDetail } from '../utils/oauth';
export default function LinkTelegramCallback() {
const { t } = useTranslation();

View File

@@ -21,7 +21,7 @@ import { closeMiniApp } from '@telegram-apps/sdk-react';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from './OAuthCallback';
import { saveOAuthState } from '../utils/oauth';
import { consumeReferralCode, getPendingReferralCode } from '../utils/referral';
export default function Login() {

View File

@@ -3,50 +3,17 @@ import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { authApi } from '../api/auth';
import {
peekLinkOAuthState,
clearLinkOAuthState,
loadOAuthState,
clearOAuthState,
getErrorDetail,
} from '../utils/oauth';
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';
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() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -96,15 +63,12 @@ export default function OAuthCallback() {
provider = linkSaved.provider;
state = linkSaved.state;
} else {
// Peek at login state first; only clear if it matches URL state
const loginState = sessionStorage.getItem(OAUTH_STATE_KEY);
const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
if (loginState && loginProvider && loginState === urlState) {
sessionStorage.removeItem(OAUTH_STATE_KEY);
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
const loginSaved = loadOAuthState();
if (loginSaved && loginSaved.state === urlState) {
clearOAuthState();
mode = 'login';
provider = loginProvider;
state = loginState;
provider = loginSaved.provider;
state = loginSaved.state;
}
}

View 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
View 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;
}