mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor: migrate to eslint flat config and format codebase with prettier
- Remove legacy .eslintrc.cjs and .eslintignore - Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url) - Add .prettierrc and .prettierignore - Format entire codebase with prettier
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../api/branding'
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../api/branding';
|
||||
|
||||
const YM_SCRIPT_ID = 'ym-counter-script'
|
||||
const GTAG_LOADER_ID = 'gtag-loader-script'
|
||||
const GTAG_INIT_ID = 'gtag-init-script'
|
||||
const YM_SCRIPT_ID = 'ym-counter-script';
|
||||
const GTAG_LOADER_ID = 'gtag-loader-script';
|
||||
const GTAG_INIT_ID = 'gtag-init-script';
|
||||
|
||||
function removeElement(id: string) {
|
||||
document.getElementById(id)?.remove()
|
||||
document.getElementById(id)?.remove();
|
||||
}
|
||||
|
||||
function injectYandexMetrika(counterId: string) {
|
||||
if (document.getElementById(YM_SCRIPT_ID)) return
|
||||
if (document.getElementById(YM_SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = YM_SCRIPT_ID
|
||||
script.type = 'text/javascript'
|
||||
const script = document.createElement('script');
|
||||
script.id = YM_SCRIPT_ID;
|
||||
script.type = 'text/javascript';
|
||||
script.textContent = `
|
||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
@@ -26,30 +26,30 @@ function injectYandexMetrika(counterId: string) {
|
||||
trackLinks:true,
|
||||
accurateTrackBounce:true
|
||||
});
|
||||
`
|
||||
document.head.appendChild(script)
|
||||
`;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function injectGoogleAds(conversionId: string) {
|
||||
if (document.getElementById(GTAG_LOADER_ID)) return
|
||||
if (document.getElementById(GTAG_LOADER_ID)) return;
|
||||
|
||||
// External gtag.js loader
|
||||
const loader = document.createElement('script')
|
||||
loader.id = GTAG_LOADER_ID
|
||||
loader.async = true
|
||||
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`
|
||||
document.head.appendChild(loader)
|
||||
const loader = document.createElement('script');
|
||||
loader.id = GTAG_LOADER_ID;
|
||||
loader.async = true;
|
||||
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`;
|
||||
document.head.appendChild(loader);
|
||||
|
||||
// Init script
|
||||
const init = document.createElement('script')
|
||||
init.id = GTAG_INIT_ID
|
||||
const init = document.createElement('script');
|
||||
init.id = GTAG_INIT_ID;
|
||||
init.textContent = `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${conversionId}');
|
||||
`
|
||||
document.head.appendChild(init)
|
||||
`;
|
||||
document.head.appendChild(init);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,24 +61,24 @@ export function useAnalyticsCounters() {
|
||||
queryKey: ['analytics-counters'],
|
||||
queryFn: brandingApi.getAnalyticsCounters,
|
||||
staleTime: 5 * 60 * 1000, // 5 min
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
if (!data) return;
|
||||
|
||||
// Yandex Metrika
|
||||
if (data.yandex_metrika_id) {
|
||||
injectYandexMetrika(data.yandex_metrika_id)
|
||||
injectYandexMetrika(data.yandex_metrika_id);
|
||||
} else {
|
||||
removeElement(YM_SCRIPT_ID)
|
||||
removeElement(YM_SCRIPT_ID);
|
||||
}
|
||||
|
||||
// Google Ads
|
||||
if (data.google_ads_id) {
|
||||
injectGoogleAds(data.google_ads_id)
|
||||
injectGoogleAds(data.google_ads_id);
|
||||
} else {
|
||||
removeElement(GTAG_LOADER_ID)
|
||||
removeElement(GTAG_INIT_ID)
|
||||
removeElement(GTAG_LOADER_ID);
|
||||
removeElement(GTAG_INIT_ID);
|
||||
}
|
||||
}, [data])
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { currencyApi, type ExchangeRates } from '../api/currency'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { currencyApi, type ExchangeRates } from '../api/currency';
|
||||
|
||||
// Map language to currency
|
||||
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
||||
@@ -8,17 +8,17 @@ const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
||||
en: 'USD',
|
||||
zh: 'CNY',
|
||||
fa: 'IRR',
|
||||
}
|
||||
};
|
||||
|
||||
// Default rates for fallback
|
||||
const DEFAULT_RATES: ExchangeRates = {
|
||||
USD: 100,
|
||||
CNY: 14,
|
||||
IRR: 0.0024,
|
||||
}
|
||||
};
|
||||
|
||||
export function useCurrency() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
// Fetch exchange rates
|
||||
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
|
||||
@@ -27,72 +27,68 @@ export function useCurrency() {
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
})
|
||||
});
|
||||
|
||||
// Get current language and currency
|
||||
const currentLanguage = i18n.language
|
||||
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD'
|
||||
const currentLanguage = i18n.language;
|
||||
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
|
||||
|
||||
// Check if current language is Russian (no conversion needed)
|
||||
const isRussian = currentLanguage === 'ru'
|
||||
const isRussian = currentLanguage === 'ru';
|
||||
|
||||
// Get currency symbol from translations
|
||||
const currencySymbol = t('common.currency')
|
||||
const currencySymbol = t('common.currency');
|
||||
|
||||
// Format amount with currency conversion
|
||||
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
|
||||
if (isRussian) {
|
||||
return rubAmount.toFixed(decimals)
|
||||
return rubAmount.toFixed(decimals);
|
||||
}
|
||||
|
||||
// Convert to target currency
|
||||
const convertedAmount = currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
exchangeRates,
|
||||
);
|
||||
|
||||
// For IRR (Iranian Toman), use no decimals as amounts are large
|
||||
if (targetCurrency === 'IRR') {
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR')
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
return convertedAmount.toFixed(decimals)
|
||||
}
|
||||
return convertedAmount.toFixed(decimals);
|
||||
};
|
||||
|
||||
// Format amount with currency symbol
|
||||
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
|
||||
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`
|
||||
}
|
||||
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||
};
|
||||
|
||||
// Format amount with + sign (for earnings/bonuses)
|
||||
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
|
||||
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`
|
||||
}
|
||||
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||
};
|
||||
|
||||
// Get raw converted amount (for calculations)
|
||||
const convertAmount = (rubAmount: number): number => {
|
||||
if (isRussian) {
|
||||
return rubAmount
|
||||
return rubAmount;
|
||||
}
|
||||
return currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
}
|
||||
exchangeRates,
|
||||
);
|
||||
};
|
||||
|
||||
// Convert from user's currency back to rubles
|
||||
const convertToRub = (amount: number): number => {
|
||||
if (isRussian) {
|
||||
return amount
|
||||
return amount;
|
||||
}
|
||||
return currencyApi.convertToRub(
|
||||
amount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
}
|
||||
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
|
||||
};
|
||||
|
||||
return {
|
||||
exchangeRates,
|
||||
@@ -104,5 +100,5 @@ export function useCurrency() {
|
||||
formatPositive,
|
||||
convertAmount,
|
||||
convertToRub,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'admin_favorite_settings'
|
||||
const STORAGE_KEY = 'admin_favorite_settings';
|
||||
|
||||
export function useFavoriteSettings() {
|
||||
const [favorites, setFavorites] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Sync to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites))
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}, [favorites])
|
||||
}, [favorites]);
|
||||
|
||||
const toggleFavorite = useCallback((settingKey: string) => {
|
||||
setFavorites(prev => {
|
||||
setFavorites((prev) => {
|
||||
if (prev.includes(settingKey)) {
|
||||
return prev.filter(key => key !== settingKey)
|
||||
return prev.filter((key) => key !== settingKey);
|
||||
} else {
|
||||
return [...prev, settingKey]
|
||||
return [...prev, settingKey];
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFavorite = useCallback((settingKey: string) => {
|
||||
return favorites.includes(settingKey)
|
||||
}, [favorites])
|
||||
const isFavorite = useCallback(
|
||||
(settingKey: string) => {
|
||||
return favorites.includes(settingKey);
|
||||
},
|
||||
[favorites],
|
||||
);
|
||||
|
||||
const clearFavorites = useCallback(() => {
|
||||
setFavorites([])
|
||||
}, [])
|
||||
setFavorites([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
clearFavorites,
|
||||
count: favorites.length
|
||||
}
|
||||
count: favorites.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh?: () => void | Promise<void>
|
||||
threshold?: number // How far to pull before triggering refresh (px)
|
||||
disabled?: boolean
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
threshold?: number; // How far to pull before triggering refresh (px)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function usePullToRefresh({
|
||||
@@ -11,150 +11,152 @@ export function usePullToRefresh({
|
||||
threshold = 80,
|
||||
disabled = false,
|
||||
}: UsePullToRefreshOptions = {}) {
|
||||
const [isPulling, setIsPulling] = useState(false)
|
||||
const [pullDistance, setPullDistance] = useState(0)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const startY = useRef(0)
|
||||
const currentY = useRef(0)
|
||||
const startY = useRef(0);
|
||||
const currentY = useRef(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (onRefresh) {
|
||||
setIsRefreshing(true)
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh()
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
} else {
|
||||
// Default: reload the page
|
||||
window.location.reload()
|
||||
window.location.reload();
|
||||
}
|
||||
}, [onRefresh])
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (disabled) return;
|
||||
|
||||
// Check if a modal/overlay is currently open
|
||||
const isModalOpen = (): boolean => {
|
||||
// Check if body scroll is locked (common pattern for modals)
|
||||
if (document.body.style.overflow === 'hidden') return true
|
||||
if (document.body.style.overflow === 'hidden') return true;
|
||||
// Check for high z-index fixed elements (modals typically use z-index > 50)
|
||||
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]')
|
||||
const fixedElements = document.querySelectorAll(
|
||||
'[style*="position: fixed"], [style*="position:fixed"]',
|
||||
);
|
||||
for (const el of fixedElements) {
|
||||
const style = window.getComputedStyle(el)
|
||||
const zIndex = parseInt(style.zIndex, 10)
|
||||
const style = window.getComputedStyle(el);
|
||||
const zIndex = parseInt(style.zIndex, 10);
|
||||
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if element or any parent is scrollable and not at top
|
||||
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
|
||||
while (element && element !== document.body) {
|
||||
const style = window.getComputedStyle(element)
|
||||
const overflowY = style.overflowY
|
||||
const isScrollable = overflowY === 'auto' || overflowY === 'scroll'
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
|
||||
|
||||
if (isScrollable && element.scrollHeight > element.clientHeight) {
|
||||
// Element is scrollable - check if it's at the top
|
||||
if (element.scrollTop > 0) {
|
||||
return true // Not at top, don't trigger pull-to-refresh
|
||||
return true; // Not at top, don't trigger pull-to-refresh
|
||||
}
|
||||
}
|
||||
element = element.parentElement
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Don't trigger if a modal is open
|
||||
if (isModalOpen()) return
|
||||
if (isModalOpen()) return;
|
||||
|
||||
// Only trigger if at top of page
|
||||
if (window.scrollY > 5) return
|
||||
if (window.scrollY > 5) return;
|
||||
|
||||
// Don't trigger if touch started inside a scrollable container that's not at top
|
||||
const target = e.target as HTMLElement
|
||||
if (isInsideScrollableContainer(target)) return
|
||||
const target = e.target as HTMLElement;
|
||||
if (isInsideScrollableContainer(target)) return;
|
||||
|
||||
startY.current = e.touches[0].clientY
|
||||
currentY.current = e.touches[0].clientY
|
||||
}
|
||||
startY.current = e.touches[0].clientY;
|
||||
currentY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (startY.current === 0) return
|
||||
if (startY.current === 0) return;
|
||||
|
||||
// Cancel if modal opened during gesture
|
||||
if (isModalOpen()) {
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
return
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.scrollY > 5) {
|
||||
// User scrolled down, reset
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
return
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check during move - user might start at top then scroll inside container
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement;
|
||||
if (isInsideScrollableContainer(target)) {
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
return
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentY.current = e.touches[0].clientY
|
||||
const diff = currentY.current - startY.current
|
||||
currentY.current = e.touches[0].clientY;
|
||||
const diff = currentY.current - startY.current;
|
||||
|
||||
// Only track downward pulls
|
||||
if (diff > 0) {
|
||||
// Apply resistance - pull distance is less than actual finger movement
|
||||
const resistance = 0.4
|
||||
const distance = Math.min(diff * resistance, threshold * 1.5)
|
||||
setPullDistance(distance)
|
||||
setIsPulling(true)
|
||||
const resistance = 0.4;
|
||||
const distance = Math.min(diff * resistance, threshold * 1.5);
|
||||
setPullDistance(distance);
|
||||
setIsPulling(true);
|
||||
|
||||
// Prevent default scroll if we're pulling
|
||||
if (distance > 10) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (pullDistance >= threshold && !isRefreshing) {
|
||||
handleRefresh()
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
}
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleTouchStart)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
pullDistance,
|
||||
isRefreshing,
|
||||
progress: Math.min(pullDistance / threshold, 1),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
|
||||
// Get cached fullscreen setting
|
||||
export const getCachedFullscreenEnabled = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
|
||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set cached fullscreen setting
|
||||
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
|
||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for Telegram WebApp API integration
|
||||
@@ -26,102 +26,112 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||
*/
|
||||
export function useTelegramWebApp() {
|
||||
// Initialize synchronously to avoid flash/flicker on first render
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false)
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp)
|
||||
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
|
||||
const [safeAreaInset, setSafeAreaInset] = useState(
|
||||
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
|
||||
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApp) {
|
||||
setIsTelegramWebApp(false)
|
||||
return
|
||||
setIsTelegramWebApp(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTelegramWebApp(true)
|
||||
setIsFullscreen(webApp.isFullscreen || false)
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setIsTelegramWebApp(true);
|
||||
setIsFullscreen(webApp.isFullscreen || false);
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||
setContentSafeAreaInset(
|
||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
|
||||
// Expand WebApp to full height
|
||||
webApp.expand()
|
||||
webApp.ready()
|
||||
webApp.expand();
|
||||
webApp.ready();
|
||||
|
||||
// Listen for fullscreen changes
|
||||
const handleFullscreenChanged = () => {
|
||||
setIsFullscreen(webApp.isFullscreen || false)
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}
|
||||
setIsFullscreen(webApp.isFullscreen || false);
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||
setContentSafeAreaInset(
|
||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
// Listen for safe area changes
|
||||
const handleSafeAreaChanged = () => {
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||
setContentSafeAreaInset(
|
||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
|
||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
|
||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
|
||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
||||
|
||||
return () => {
|
||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
|
||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
||||
}
|
||||
}, [webApp])
|
||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
|
||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
|
||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
||||
};
|
||||
}, [webApp]);
|
||||
|
||||
const requestFullscreen = useCallback(() => {
|
||||
if (webApp?.requestFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen()
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Fullscreen not supported:', e)
|
||||
console.warn('Fullscreen not supported:', e);
|
||||
}
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
const exitFullscreen = useCallback(() => {
|
||||
if (webApp?.exitFullscreen) {
|
||||
try {
|
||||
webApp.exitFullscreen()
|
||||
webApp.exitFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Exit fullscreen failed:', e)
|
||||
console.warn('Exit fullscreen failed:', e);
|
||||
}
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
exitFullscreen()
|
||||
exitFullscreen();
|
||||
} else {
|
||||
requestFullscreen()
|
||||
requestFullscreen();
|
||||
}
|
||||
}, [isFullscreen, requestFullscreen, exitFullscreen])
|
||||
}, [isFullscreen, requestFullscreen, exitFullscreen]);
|
||||
|
||||
const disableVerticalSwipes = useCallback(() => {
|
||||
try {
|
||||
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes()
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Not supported in this version
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
const enableVerticalSwipes = useCallback(() => {
|
||||
try {
|
||||
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.enableVerticalSwipes()
|
||||
webApp.enableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Not supported in this version
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
// Check if fullscreen is supported (Bot API 8.0+)
|
||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
|
||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
|
||||
|
||||
return {
|
||||
isTelegramWebApp,
|
||||
@@ -135,7 +145,7 @@ export function useTelegramWebApp() {
|
||||
disableVerticalSwipes,
|
||||
enableVerticalSwipes,
|
||||
webApp,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,27 +153,27 @@ export function useTelegramWebApp() {
|
||||
* Call this in main.tsx or App.tsx
|
||||
*/
|
||||
export function initTelegramWebApp() {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (webApp) {
|
||||
webApp.ready()
|
||||
webApp.expand()
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
|
||||
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||
try {
|
||||
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes()
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Swipe control not supported in this version
|
||||
}
|
||||
|
||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled()
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen()
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Auto-fullscreen failed:', e)
|
||||
console.warn('Auto-fullscreen failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +1,224 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
const THEME_KEY = 'cabinet-theme'
|
||||
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes'
|
||||
const THEME_KEY = 'cabinet-theme';
|
||||
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
|
||||
|
||||
// Fetch enabled themes from API
|
||||
async function fetchEnabledThemes(): Promise<EnabledThemes> {
|
||||
try {
|
||||
const data = await themeColorsApi.getEnabledThemes()
|
||||
const data = await themeColorsApi.getEnabledThemes();
|
||||
// Cache in localStorage for faster subsequent loads
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data))
|
||||
return data
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
|
||||
return data;
|
||||
} catch {
|
||||
// Ignore errors, use cached or default
|
||||
}
|
||||
// Try to get from cache
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached)
|
||||
return JSON.parse(cached);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
return DEFAULT_ENABLED_THEMES;
|
||||
}
|
||||
|
||||
// Get cached enabled themes synchronously
|
||||
function getCachedEnabledThemes(): EnabledThemes {
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached)
|
||||
return JSON.parse(cached);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
return DEFAULT_ENABLED_THEMES;
|
||||
}
|
||||
|
||||
// Custom event for same-tab updates
|
||||
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged'
|
||||
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
|
||||
|
||||
// Update cache (called from admin settings)
|
||||
export function updateEnabledThemesCache(themes: EnabledThemes) {
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes))
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes));
|
||||
// Dispatch custom event for same-tab updates
|
||||
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }))
|
||||
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const enabled = getCachedEnabledThemes()
|
||||
const enabled = getCachedEnabledThemes();
|
||||
|
||||
// Check localStorage first
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null
|
||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (stored === 'light' && enabled.light) {
|
||||
return 'light'
|
||||
return 'light';
|
||||
}
|
||||
if (stored === 'dark' && enabled.dark) {
|
||||
return 'dark'
|
||||
return 'dark';
|
||||
}
|
||||
// If stored theme is disabled, use the enabled one
|
||||
if (stored && !enabled[stored]) {
|
||||
return enabled.dark ? 'dark' : 'light'
|
||||
return enabled.dark ? 'dark' : 'light';
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
||||
return 'light'
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
// Default to dark if enabled, otherwise light
|
||||
return enabled.dark ? 'dark' : 'light'
|
||||
})
|
||||
return enabled.dark ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
// Fetch enabled themes on mount
|
||||
useEffect(() => {
|
||||
fetchEnabledThemes().then((data) => {
|
||||
setEnabledThemes(data)
|
||||
setIsLoading(false)
|
||||
setEnabledThemes(data);
|
||||
setIsLoading(false);
|
||||
// If current theme is disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
})
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
|
||||
try {
|
||||
const data = JSON.parse(e.newValue) as EnabledThemes
|
||||
setEnabledThemes(data)
|
||||
const data = JSON.parse(e.newValue) as EnabledThemes;
|
||||
setEnabledThemes(data);
|
||||
// If current theme is now disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [theme])
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for same-tab enabled themes changes (from admin settings)
|
||||
useEffect(() => {
|
||||
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
|
||||
const data = e.detail
|
||||
setEnabledThemes(data)
|
||||
const data = e.detail;
|
||||
setEnabledThemes(data);
|
||||
// If current theme is now disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
|
||||
return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
|
||||
}, [theme])
|
||||
window.addEventListener(
|
||||
ENABLED_THEMES_CHANGED_EVENT,
|
||||
handleEnabledThemesChange as EventListener,
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
ENABLED_THEMES_CHANGED_EVENT,
|
||||
handleEnabledThemesChange as EventListener,
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
// Apply theme to document - also check if theme is disabled and switch
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const root = document.documentElement;
|
||||
|
||||
// If current theme is disabled, switch to the enabled one
|
||||
if (!enabledThemes[theme]) {
|
||||
const newTheme = enabledThemes.dark ? 'dark' : 'light'
|
||||
const newTheme = enabledThemes.dark ? 'dark' : 'light';
|
||||
if (newTheme !== theme) {
|
||||
setThemeState(newTheme)
|
||||
return // Will re-run with correct theme
|
||||
setThemeState(newTheme);
|
||||
return; // Will re-run with correct theme
|
||||
}
|
||||
}
|
||||
|
||||
if (theme === 'light') {
|
||||
root.classList.remove('dark')
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark');
|
||||
root.classList.add('light');
|
||||
} else {
|
||||
root.classList.remove('light')
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
}, [theme, enabledThemes])
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme, enabledThemes]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const stored = localStorage.getItem(THEME_KEY)
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
// Only auto-switch if user hasn't set a preference and theme is enabled
|
||||
if (!stored) {
|
||||
const newTheme = e.matches ? 'light' : 'dark'
|
||||
const newTheme = e.matches ? 'light' : 'dark';
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme)
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [enabledThemes])
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [enabledThemes]);
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
// Only allow setting if theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme)
|
||||
}
|
||||
}, [enabledThemes])
|
||||
const setTheme = useCallback(
|
||||
(newTheme: Theme) => {
|
||||
// Only allow setting if theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
},
|
||||
[enabledThemes],
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const newTheme = prev === 'dark' ? 'light' : 'dark'
|
||||
const newTheme = prev === 'dark' ? 'light' : 'dark';
|
||||
// Only toggle if the new theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
return newTheme
|
||||
return newTheme;
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [enabledThemes])
|
||||
return prev;
|
||||
});
|
||||
}, [enabledThemes]);
|
||||
|
||||
const isDark = theme === 'dark'
|
||||
const isLight = theme === 'light'
|
||||
const isDark = theme === 'dark';
|
||||
const isLight = theme === 'light';
|
||||
|
||||
// Check if theme switching is available (both themes enabled and loaded)
|
||||
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light
|
||||
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light;
|
||||
|
||||
// Refresh enabled themes from API
|
||||
const refreshEnabledThemes = useCallback(() => {
|
||||
fetchEnabledThemes().then((data) => {
|
||||
setEnabledThemes(data)
|
||||
setEnabledThemes(data);
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
})
|
||||
}, [theme])
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
@@ -220,5 +230,5 @@ export function useTheme() {
|
||||
canToggle,
|
||||
isLoading,
|
||||
refreshEnabledThemes,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
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'
|
||||
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]
|
||||
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 }
|
||||
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 { 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
|
||||
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)
|
||||
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
|
||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
|
||||
break;
|
||||
case gNorm:
|
||||
h = ((bNorm - rNorm) / d + 2) / 6
|
||||
break
|
||||
h = ((bNorm - rNorm) / d + 2) / 6;
|
||||
break;
|
||||
case bNorm:
|
||||
h = ((rNorm - gNorm) / d + 4) / 6
|
||||
break
|
||||
h = ((rNorm - gNorm) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
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
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const a = s * Math.min(l, 1 - l)
|
||||
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)
|
||||
}
|
||||
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) }
|
||||
return { r: f(0), g: f(8), b: f(4) };
|
||||
}
|
||||
|
||||
// Convert RGB to string format for CSS variable
|
||||
function rgbToString(r: number, g: number, b: number): string {
|
||||
return `${r}, ${g}, ${b}`
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
// Generate color palette from base color (returns RGB strings)
|
||||
function generatePalette(baseHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(baseHex)
|
||||
const { h, s } = hexToHsl(baseHex);
|
||||
|
||||
// Lightness values for each shade level (from light to dark)
|
||||
const lightnessMap: Record<number, number> = {
|
||||
@@ -85,118 +85,151 @@ function generatePalette(baseHex: string): ColorPalette {
|
||||
800: 26,
|
||||
900: 18,
|
||||
950: 10,
|
||||
}
|
||||
};
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
const palette: Partial<ColorPalette> = {};
|
||||
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
const lightness = lightnessMap[shade]
|
||||
const lightness = lightnessMap[shade];
|
||||
// Adjust saturation slightly for very light/dark shades
|
||||
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s
|
||||
const { r, g, b } = hslToRgb(h, adjustedS, lightness)
|
||||
palette[shade] = rgbToString(r, g, b)
|
||||
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s;
|
||||
const { r, g, b } = hslToRgb(h, adjustedS, lightness);
|
||||
palette[shade] = rgbToString(r, g, b);
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
return palette as ColorPalette;
|
||||
}
|
||||
|
||||
// Interpolate between two RGB colors
|
||||
function interpolateRgb(
|
||||
rgb1: { r: number; g: number; b: number },
|
||||
rgb2: { r: number; g: number; b: number },
|
||||
factor: number
|
||||
factor: number,
|
||||
): string {
|
||||
return rgbToString(
|
||||
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
||||
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
|
||||
)
|
||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||
export function applyThemeColors(colors: ThemeColors): void {
|
||||
const root = document.documentElement
|
||||
const root = document.documentElement;
|
||||
|
||||
// Generate palettes from status colors
|
||||
const accentPalette = generatePalette(colors.accent)
|
||||
const successPalette = generatePalette(colors.success)
|
||||
const warningPalette = generatePalette(colors.warning)
|
||||
const errorPalette = generatePalette(colors.error)
|
||||
const accentPalette = generatePalette(colors.accent);
|
||||
const successPalette = generatePalette(colors.success);
|
||||
const warningPalette = generatePalette(colors.warning);
|
||||
const errorPalette = generatePalette(colors.error);
|
||||
|
||||
// === DARK THEME PALETTE ===
|
||||
// Convert hex colors to RGB
|
||||
const darkBgRgb = hexToRgb(colors.darkBackground)
|
||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
|
||||
const darkTextRgb = hexToRgb(colors.darkText)
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
|
||||
const darkBgRgb = hexToRgb(colors.darkBackground);
|
||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface);
|
||||
const darkTextRgb = hexToRgb(colors.darkText);
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
|
||||
|
||||
// Apply dark palette with actual user colors:
|
||||
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
||||
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
|
||||
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
|
||||
root.style.setProperty(
|
||||
'--color-dark-50',
|
||||
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-dark-100',
|
||||
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33));
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
|
||||
root.style.setProperty(
|
||||
'--color-dark-400',
|
||||
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
|
||||
);
|
||||
|
||||
// Transition colors (500-700): interpolate between secondary text and surface
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
|
||||
|
||||
// Surface/card colors (800-850): surface color
|
||||
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
|
||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
|
||||
root.style.setProperty(
|
||||
'--color-dark-800',
|
||||
rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5));
|
||||
|
||||
// Background colors (900-950): background color
|
||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
|
||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
|
||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
|
||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
|
||||
|
||||
// === LIGHT THEME PALETTE ===
|
||||
const lightBgRgb = hexToRgb(colors.lightBackground)
|
||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
|
||||
const lightTextRgb = hexToRgb(colors.lightText)
|
||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
|
||||
const lightBgRgb = hexToRgb(colors.lightBackground);
|
||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface);
|
||||
const lightTextRgb = hexToRgb(colors.lightText);
|
||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary);
|
||||
|
||||
// Apply champagne palette with actual user colors:
|
||||
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
|
||||
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
|
||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
|
||||
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
|
||||
root.style.setProperty(
|
||||
'--color-champagne-50',
|
||||
rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3));
|
||||
root.style.setProperty(
|
||||
'--color-champagne-200',
|
||||
rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
|
||||
|
||||
// Transition colors (500-600): between bg and text
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
|
||||
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
|
||||
root.style.setProperty(
|
||||
'--color-champagne-600',
|
||||
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
|
||||
);
|
||||
|
||||
// Text colors (700-950): secondary to primary text
|
||||
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
|
||||
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
|
||||
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
root.style.setProperty(
|
||||
'--color-champagne-700',
|
||||
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-800',
|
||||
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-900',
|
||||
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-950',
|
||||
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||
);
|
||||
|
||||
// === STATUS COLOR PALETTES ===
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]);
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
|
||||
}
|
||||
|
||||
// Apply semantic colors (hex for direct use)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground)
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface)
|
||||
root.style.setProperty('--color-dark-text', colors.darkText)
|
||||
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground);
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface);
|
||||
root.style.setProperty('--color-dark-text', colors.darkText);
|
||||
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary);
|
||||
|
||||
root.style.setProperty('--color-light-bg', colors.lightBackground)
|
||||
root.style.setProperty('--color-light-surface', colors.lightSurface)
|
||||
root.style.setProperty('--color-light-text', colors.lightText)
|
||||
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary)
|
||||
root.style.setProperty('--color-light-bg', colors.lightBackground);
|
||||
root.style.setProperty('--color-light-surface', colors.lightSurface);
|
||||
root.style.setProperty('--color-light-text', colors.lightText);
|
||||
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary);
|
||||
}
|
||||
|
||||
export function useThemeColors() {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: colors,
|
||||
@@ -208,22 +241,22 @@ export function useThemeColors() {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
})
|
||||
});
|
||||
|
||||
// Apply colors when loaded or changed
|
||||
useEffect(() => {
|
||||
const colorsToApply = colors || DEFAULT_THEME_COLORS
|
||||
applyThemeColors(colorsToApply)
|
||||
}, [colors])
|
||||
const colorsToApply = colors || DEFAULT_THEME_COLORS;
|
||||
applyThemeColors(colorsToApply);
|
||||
}, [colors]);
|
||||
|
||||
const invalidateColors = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
};
|
||||
|
||||
return {
|
||||
colors: colors || DEFAULT_THEME_COLORS,
|
||||
isLoading,
|
||||
error,
|
||||
invalidateColors,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,156 +1,159 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export interface WSMessage {
|
||||
type: string
|
||||
ticket_id?: number
|
||||
message?: string
|
||||
title?: string
|
||||
user_id?: number
|
||||
is_admin?: boolean
|
||||
type: string;
|
||||
ticket_id?: number;
|
||||
message?: string;
|
||||
title?: string;
|
||||
user_id?: number;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onMessage?: (message: WSMessage) => void
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onMessage?: (message: WSMessage) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const { accessToken, isAuthenticated } = useAuthStore()
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const maxReconnectAttempts = 5
|
||||
const optionsRef = useRef(options)
|
||||
const { accessToken, isAuthenticated } = useAuthStore();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const optionsRef = useRef(options);
|
||||
|
||||
// Update options ref when they change
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
}, [options])
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!accessToken || !isAuthenticated) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't reconnect if already connected
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup()
|
||||
cleanup();
|
||||
|
||||
// Build WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
let host = window.location.host
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let host = window.location.host;
|
||||
|
||||
// Handle VITE_API_URL - can be absolute URL or relative path
|
||||
const apiUrl = import.meta.env.VITE_API_URL
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
|
||||
try {
|
||||
host = new URL(apiUrl).host
|
||||
host = new URL(apiUrl).host;
|
||||
} catch {
|
||||
// If URL parsing fails, use window.location.host
|
||||
}
|
||||
}
|
||||
// If apiUrl is relative (like /api), use window.location.host
|
||||
|
||||
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`
|
||||
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (isDev) console.log('[WS] Connected')
|
||||
setIsConnected(true)
|
||||
reconnectAttemptsRef.current = 0
|
||||
optionsRef.current.onConnect?.()
|
||||
if (isDev) console.log('[WS] Connected');
|
||||
setIsConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
optionsRef.current.onConnect?.();
|
||||
|
||||
// Setup ping interval (every 25 seconds)
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 25000)
|
||||
}
|
||||
}, 25000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WSMessage
|
||||
const message = JSON.parse(event.data) as WSMessage;
|
||||
|
||||
// Ignore pong messages
|
||||
if (message.type === 'pong' || message.type === 'connected') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
optionsRef.current.onMessage?.(message)
|
||||
optionsRef.current.onMessage?.(message);
|
||||
} catch (e) {
|
||||
if (isDev) console.error('[WS] Failed to parse message:', e)
|
||||
if (isDev) console.error('[WS] Failed to parse message:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason)
|
||||
setIsConnected(false)
|
||||
optionsRef.current.onDisconnect?.()
|
||||
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
optionsRef.current.onDisconnect?.();
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Attempt to reconnect if not closed intentionally
|
||||
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
|
||||
if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`)
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
if (isDev)
|
||||
console.log(
|
||||
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++
|
||||
connect()
|
||||
}, delay)
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
if (isDev) console.error('[WS] Error:', error)
|
||||
}
|
||||
if (isDev) console.error('[WS] Error:', error);
|
||||
};
|
||||
} catch (e) {
|
||||
if (isDev) console.error('[WS] Failed to connect:', e)
|
||||
if (isDev) console.error('[WS] Failed to connect:', e);
|
||||
}
|
||||
}, [accessToken, isAuthenticated, cleanup])
|
||||
}, [accessToken, isAuthenticated, cleanup]);
|
||||
|
||||
// Connect when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && accessToken) {
|
||||
connect()
|
||||
connect();
|
||||
} else {
|
||||
cleanup()
|
||||
setIsConnected(false)
|
||||
cleanup();
|
||||
setIsConnected(false);
|
||||
}
|
||||
|
||||
return cleanup
|
||||
}, [isAuthenticated, accessToken, connect, cleanup])
|
||||
return cleanup;
|
||||
}, [isAuthenticated, accessToken, connect, cleanup]);
|
||||
|
||||
return { isConnected }
|
||||
return { isConnected };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user