mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Add files via upload
This commit is contained in:
108
src/hooks/useCurrency.ts
Normal file
108
src/hooks/useCurrency.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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'> = {
|
||||
ru: '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()
|
||||
|
||||
// Fetch exchange rates
|
||||
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
|
||||
queryKey: ['exchange-rates'],
|
||||
queryFn: currencyApi.getExchangeRates,
|
||||
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'
|
||||
|
||||
// Check if current language is Russian (no conversion needed)
|
||||
const isRussian = currentLanguage === 'ru'
|
||||
|
||||
// Get currency symbol from translations
|
||||
const currencySymbol = t('common.currency')
|
||||
|
||||
// Format amount with currency conversion
|
||||
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
|
||||
if (isRussian) {
|
||||
return rubAmount.toFixed(decimals)
|
||||
}
|
||||
|
||||
// Convert to target currency
|
||||
const convertedAmount = currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
|
||||
// For IRR (Iranian Toman), use no decimals as amounts are large
|
||||
if (targetCurrency === 'IRR') {
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
return convertedAmount.toFixed(decimals)
|
||||
}
|
||||
|
||||
// Format amount with currency symbol
|
||||
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
|
||||
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}`
|
||||
}
|
||||
|
||||
// Get raw converted amount (for calculations)
|
||||
const convertAmount = (rubAmount: number): number => {
|
||||
if (isRussian) {
|
||||
return rubAmount
|
||||
}
|
||||
return currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
}
|
||||
|
||||
// Convert from user's currency back to rubles
|
||||
const convertToRub = (amount: number): number => {
|
||||
if (isRussian) {
|
||||
return amount
|
||||
}
|
||||
return currencyApi.convertToRub(
|
||||
amount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
exchangeRates,
|
||||
targetCurrency,
|
||||
isRussian,
|
||||
currencySymbol,
|
||||
formatAmount,
|
||||
formatWithCurrency,
|
||||
formatPositive,
|
||||
convertAmount,
|
||||
convertToRub,
|
||||
}
|
||||
}
|
||||
224
src/hooks/useTheme.ts
Normal file
224
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
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()
|
||||
// Cache in localStorage for faster subsequent loads
|
||||
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)
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached)
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
}
|
||||
|
||||
// Get cached enabled themes synchronously
|
||||
function getCachedEnabledThemes(): EnabledThemes {
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached)
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
}
|
||||
|
||||
// Custom event for same-tab updates
|
||||
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))
|
||||
// Dispatch custom event for same-tab updates
|
||||
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 [theme, setThemeState] = useState<Theme>(() => {
|
||||
const enabled = getCachedEnabledThemes()
|
||||
|
||||
// Check localStorage first
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null
|
||||
if (stored === 'light' && enabled.light) {
|
||||
return 'light'
|
||||
}
|
||||
if (stored === 'dark' && enabled.dark) {
|
||||
return 'dark'
|
||||
}
|
||||
// If stored theme is disabled, use the enabled one
|
||||
if (stored && !enabled[stored]) {
|
||||
return enabled.dark ? 'dark' : 'light'
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
||||
return 'light'
|
||||
}
|
||||
}
|
||||
// Default to dark if enabled, otherwise light
|
||||
return enabled.dark ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
// Fetch enabled themes on mount
|
||||
useEffect(() => {
|
||||
fetchEnabledThemes().then((data) => {
|
||||
setEnabledThemes(data)
|
||||
setIsLoading(false)
|
||||
// If current theme is disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
}
|
||||
})
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
|
||||
try {
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// If current theme is now disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
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])
|
||||
|
||||
// Apply theme to document - also check if theme is disabled and switch
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
// If current theme is disabled, switch to the enabled one
|
||||
if (!enabledThemes[theme]) {
|
||||
const newTheme = enabledThemes.dark ? 'dark' : 'light'
|
||||
if (newTheme !== theme) {
|
||||
setThemeState(newTheme)
|
||||
return // Will re-run with correct theme
|
||||
}
|
||||
}
|
||||
|
||||
if (theme === 'light') {
|
||||
root.classList.remove('dark')
|
||||
root.classList.add('light')
|
||||
} else {
|
||||
root.classList.remove('light')
|
||||
root.classList.add('dark')
|
||||
}
|
||||
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
}, [theme, enabledThemes])
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
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'
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const newTheme = prev === 'dark' ? 'light' : 'dark'
|
||||
// Only toggle if the new theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
return newTheme
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [enabledThemes])
|
||||
|
||||
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
|
||||
|
||||
// Refresh enabled themes from API
|
||||
const refreshEnabledThemes = useCallback(() => {
|
||||
fetchEnabledThemes().then((data) => {
|
||||
setEnabledThemes(data)
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
}
|
||||
})
|
||||
}, [theme])
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
isDark,
|
||||
isLight,
|
||||
enabledThemes,
|
||||
canToggle,
|
||||
isLoading,
|
||||
refreshEnabledThemes,
|
||||
}
|
||||
}
|
||||
256
src/hooks/useThemeColors.ts
Normal file
256
src/hooks/useThemeColors.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme'
|
||||
|
||||
// Convert hex to RGB values
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
// Handle shorthand hex
|
||||
if (hex.length === 4) {
|
||||
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||
}
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
// Convert hex to HSL
|
||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const rNorm = r / 255
|
||||
const gNorm = g / 255
|
||||
const bNorm = b / 255
|
||||
|
||||
const max = Math.max(rNorm, gNorm, bNorm)
|
||||
const min = Math.min(rNorm, gNorm, bNorm)
|
||||
let h = 0
|
||||
let s = 0
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case rNorm:
|
||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
|
||||
break
|
||||
case gNorm:
|
||||
h = ((bNorm - rNorm) / d + 2) / 6
|
||||
break
|
||||
case bNorm:
|
||||
h = ((rNorm - gNorm) / d + 4) / 6
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
// Convert HSL to RGB values
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
s /= 100
|
||||
l /= 100
|
||||
|
||||
const a = s * Math.min(l, 1 - l)
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||
return Math.round(255 * color)
|
||||
}
|
||||
|
||||
return { r: f(0), g: f(8), b: f(4) }
|
||||
}
|
||||
|
||||
// Convert RGB to string format for CSS variable
|
||||
function rgbToString(r: number, g: number, b: number): string {
|
||||
return `${r}, ${g}, ${b}`
|
||||
}
|
||||
|
||||
// Generate color palette from base color (returns RGB strings)
|
||||
function generatePalette(baseHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(baseHex)
|
||||
|
||||
// Lightness values for each shade level (from light to dark)
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 97,
|
||||
100: 94,
|
||||
200: 86,
|
||||
300: 76,
|
||||
400: 64,
|
||||
500: 50,
|
||||
600: 42,
|
||||
700: 34,
|
||||
800: 26,
|
||||
900: 18,
|
||||
950: 10,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
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)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Generate neutral palette from dark background color (for dark theme)
|
||||
function generateDarkPalette(darkBgHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(darkBgHex)
|
||||
|
||||
// Use very low saturation for neutral colors
|
||||
const neutralS = Math.min(s, 15)
|
||||
|
||||
// Lightness values - from very light (50) to very dark (950)
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 97,
|
||||
100: 96,
|
||||
200: 89,
|
||||
300: 80,
|
||||
400: 58,
|
||||
500: 40,
|
||||
600: 28,
|
||||
700: 20,
|
||||
800: 12,
|
||||
850: 10,
|
||||
900: 7,
|
||||
950: 4,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of [...SHADE_LEVELS, 850] as const) {
|
||||
const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50
|
||||
const { r, g, b } = hslToRgb(h, neutralS, lightness)
|
||||
palette[shade as keyof ColorPalette] = rgbToString(r, g, b)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Generate light theme palette (champagne-like)
|
||||
function generateLightPalette(lightBgHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(lightBgHex)
|
||||
|
||||
// Lightness values for light theme - inverse of dark
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 100,
|
||||
100: 98,
|
||||
200: 91,
|
||||
300: 83,
|
||||
400: 74,
|
||||
500: 64,
|
||||
600: 55,
|
||||
700: 42,
|
||||
800: 31,
|
||||
900: 21,
|
||||
950: 10,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
const lightness = lightnessMap[shade]
|
||||
const { r, g, b } = hslToRgb(h, s, lightness)
|
||||
palette[shade] = rgbToString(r, g, b)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||
export function applyThemeColors(colors: ThemeColors): void {
|
||||
const root = document.documentElement
|
||||
|
||||
// Generate palettes from base colors
|
||||
const accentPalette = generatePalette(colors.accent)
|
||||
const successPalette = generatePalette(colors.success)
|
||||
const warningPalette = generatePalette(colors.warning)
|
||||
const errorPalette = generatePalette(colors.error)
|
||||
|
||||
// Generate dark/light palettes from background colors
|
||||
const darkPalette = generateDarkPalette(colors.darkBackground)
|
||||
const champagnePalette = generateLightPalette(colors.lightBackground)
|
||||
|
||||
// Apply dark palette
|
||||
for (const shade of [...SHADE_LEVELS, 850] as const) {
|
||||
if (darkPalette[shade as keyof ColorPalette]) {
|
||||
root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette])
|
||||
}
|
||||
}
|
||||
|
||||
// Apply champagne/light palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade])
|
||||
}
|
||||
|
||||
// Apply accent palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
|
||||
}
|
||||
|
||||
// Apply success palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
|
||||
}
|
||||
|
||||
// Apply warning palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
|
||||
}
|
||||
|
||||
// Apply error palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
|
||||
}
|
||||
|
||||
// Apply semantic colors (hex for direct use in some places)
|
||||
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)
|
||||
}
|
||||
|
||||
export function useThemeColors() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
data: colors,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['theme-colors'],
|
||||
queryFn: themeColorsApi.getColors,
|
||||
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 invalidateColors = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
}
|
||||
|
||||
return {
|
||||
colors: colors || DEFAULT_THEME_COLORS,
|
||||
isLoading,
|
||||
error,
|
||||
invalidateColors,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user