From 62188b8d2e3cb090d0b27afe5cf4fcc65b3c68c2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 13 Mar 2026 19:48:32 +0300 Subject: [PATCH] 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 --- eslint.config.js | 5 +- src/components/TelegramLoginButton.tsx | 2 +- src/components/admin/BackgroundEditor.tsx | 2 +- .../backgrounds/BackgroundRenderer.tsx | 66 +----- .../background-beams-collision.tsx | 2 +- src/hooks/useAnimatedNumber.ts | 13 +- src/pages/AdminTariffs.tsx | 206 +++++++++--------- src/pages/ConnectedAccounts.tsx | 28 +-- src/pages/Connection.tsx | 2 +- src/pages/GiftSubscription.tsx | 2 +- src/pages/LinkTelegramCallback.tsx | 2 +- src/pages/Login.tsx | 2 +- src/pages/OAuthCallback.tsx | 60 +---- src/utils/backgroundConfig.ts | 63 ++++++ src/utils/oauth.ts | 48 ++++ 15 files changed, 262 insertions(+), 241 deletions(-) create mode 100644 src/utils/backgroundConfig.ts create mode 100644 src/utils/oauth.ts diff --git a/eslint.config.js b/eslint.config.js index 097d8b4..3d94809 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,7 +30,10 @@ export default tseslint.config( 'react-hooks/variables': 'off', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], '@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-eval': 'error', 'no-implied-eval': 'error', diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 037be27..7c36bd3 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -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); diff --git a/src/components/admin/BackgroundEditor.tsx b/src/components/admin/BackgroundEditor.tsx index f57f651..59c75e0 100644 --- a/src/components/admin/BackgroundEditor.tsx +++ b/src/components/admin/BackgroundEditor.tsx @@ -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'; diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index 20d2b07..df8b53e 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -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 = new Set([ - '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; - 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) ?? {}, - 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): Record { const reduced = { ...settings }; // 75% reduction (divide by 4) instead of 50% — much less GPU work @@ -94,7 +33,6 @@ function reduceMobileSettings(settings: Record): Record 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 ; } -/** 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; diff --git a/src/components/ui/backgrounds/background-beams-collision.tsx b/src/components/ui/backgrounds/background-beams-collision.tsx index 3a51de6..4e2f58d 100644 --- a/src/components/ui/backgrounds/background-beams-collision.tsx +++ b/src/components/ui/backgrounds/background-beams-collision.tsx @@ -125,7 +125,7 @@ function CollisionMechanism({ }, 2000); return () => clearTimeout(timer); - }, [collision.detected]); + }, [collision.detected, collision.coordinates]); return ( <> diff --git a/src/hooks/useAnimatedNumber.ts b/src/hooks/useAnimatedNumber.ts index 2797787..ce1896c 100644 --- a/src/hooks/useAnimatedNumber.ts +++ b/src/hooks/useAnimatedNumber.ts @@ -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(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); diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index 7817b99..f66d013 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -144,8 +144,7 @@ function SortableTariffCard({ : 'border-dark-700/50 opacity-60' }`} > -
- {/* Drag handle */} +
- {/* Content */}
-
-

{tariff.name}

- {tariff.is_daily ? ( - - {t('admin.tariffs.dailyType')} - - ) : ( - - {t('admin.tariffs.periodType')} - - )} - {tariff.is_trial_available && ( - - {t('admin.tariffs.trial')} - - )} - {tariff.show_in_gift && ( - - - - - {t('admin.tariffs.giftBadge')} - - )} - {!tariff.is_active && ( - - {t('admin.tariffs.inactive')} - - )} +
+
+
+

{tariff.name}

+ {tariff.is_daily ? ( + + {t('admin.tariffs.dailyType')} + + ) : ( + + {t('admin.tariffs.periodType')} + + )} + {tariff.is_trial_available && ( + + {t('admin.tariffs.trial')} + + )} + {tariff.show_in_gift && ( + + + + + {t('admin.tariffs.giftBadge')} + + )} + {!tariff.is_active && ( + + {t('admin.tariffs.inactive')} + + )} +
+
+ {tariff.is_daily && tariff.daily_price_kopeks > 0 && ( + + {(tariff.daily_price_kopeks / 100).toFixed(2)}{' '} + {t('admin.tariffs.currencyPerDay')} + + )} + + {tariff.traffic_limit_gb === 0 + ? t('admin.tariffs.unlimited') + : `${tariff.traffic_limit_gb} GB`} + + {t('admin.tariffs.devices', { count: tariff.device_limit })} + {t('admin.tariffs.servers', { count: tariff.servers_count })} + + {t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })} + +
+
+ +
+ + + + + + + +
-
- {tariff.is_daily && tariff.daily_price_kopeks > 0 && ( - - {(tariff.daily_price_kopeks / 100).toFixed(2)} {t('admin.tariffs.currencyPerDay')} - - )} - - {tariff.traffic_limit_gb === 0 - ? t('admin.tariffs.unlimited') - : `${tariff.traffic_limit_gb} GB`} - - {t('admin.tariffs.devices', { count: tariff.device_limit })} - {t('admin.tariffs.servers', { count: tariff.servers_count })} - {t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })} -
-
- - {/* Actions */} -
- - - - - - -
diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 2543da6..1502bb5 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -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>) => { - 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>) => { + 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; diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 8384531..24fd911 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -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) => { diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index fa1fa4a..892ad8d 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -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; diff --git a/src/pages/LinkTelegramCallback.tsx b/src/pages/LinkTelegramCallback.tsx index dd447a2..24d3561 100644 --- a/src/pages/LinkTelegramCallback.tsx +++ b/src/pages/LinkTelegramCallback.tsx @@ -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(); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 084a952..e32272e 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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() { diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index 0981ce9..3ca3db6 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -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).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; } } diff --git a/src/utils/backgroundConfig.ts b/src/utils/backgroundConfig.ts new file mode 100644 index 0000000..d085be0 --- /dev/null +++ b/src/utils/backgroundConfig.ts @@ -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 = new Set([ + '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; + 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) ?? {}, + 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); +} diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts new file mode 100644 index 0000000..b1a7256 --- /dev/null +++ b/src/utils/oauth.ts @@ -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).message; + if (typeof msg === 'string') return msg; + } + } + if (err instanceof Error) return err.message; + return null; +}