mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +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,43 +1,44 @@
|
||||
import { useEffect, useState, memo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../api/branding'
|
||||
import { useEffect, useState, memo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../api/branding';
|
||||
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled';
|
||||
|
||||
// Detect if user prefers reduced motion
|
||||
const isLowPerformance = (): boolean => {
|
||||
// Only check for reduced motion preference - let animation run everywhere else
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
return prefersReducedMotion
|
||||
}
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
return prefersReducedMotion;
|
||||
};
|
||||
|
||||
// Get cached value from localStorage
|
||||
const getCachedAnimationEnabled = (): boolean | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY)
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||
if (cached !== null) {
|
||||
return cached === 'true'
|
||||
return cached === 'true';
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Update cache in localStorage
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const setCachedAnimationEnabled = (enabled: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled))
|
||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized background component to prevent re-renders
|
||||
const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
// Start with cached value (null means unknown yet)
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
|
||||
const [isLowPerf] = useState(() => isLowPerformance())
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled());
|
||||
const [isLowPerf] = useState(() => isLowPerformance());
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
@@ -45,24 +46,24 @@ const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Update state and cache when data arrives
|
||||
useEffect(() => {
|
||||
if (animationSettings !== undefined) {
|
||||
const enabled = animationSettings.enabled
|
||||
setIsEnabled(enabled)
|
||||
setCachedAnimationEnabled(enabled)
|
||||
const enabled = animationSettings.enabled;
|
||||
setIsEnabled(enabled);
|
||||
setCachedAnimationEnabled(enabled);
|
||||
}
|
||||
}, [animationSettings])
|
||||
}, [animationSettings]);
|
||||
|
||||
// Don't render if disabled or on low-performance devices
|
||||
if (isEnabled !== true || isLowPerf) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render only 2 blobs on mobile for better performance
|
||||
const isMobile = window.innerWidth < 768
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<div className="wave-bg-container">
|
||||
@@ -75,7 +76,7 @@ const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedBackground
|
||||
export default AnimatedBackground;
|
||||
|
||||
@@ -1,238 +1,286 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string
|
||||
onChange: (color: string) => void
|
||||
label: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Check if running in Telegram WebApp
|
||||
const isTelegramWebApp = (): boolean => {
|
||||
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
||||
}
|
||||
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp;
|
||||
};
|
||||
|
||||
// Convert hex to RGB
|
||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
};
|
||||
|
||||
// Convert RGB to hex
|
||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
// Convert RGB to HSL
|
||||
const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h = 0, s = 0
|
||||
const l = (max + min) / 2
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
let h = 0,
|
||||
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 r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
|
||||
case g: h = ((b - r) / d + 2) / 6; break
|
||||
case b: h = ((r - g) / d + 4) / 6; break
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
|
||||
}
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||
};
|
||||
|
||||
// Convert HSL to RGB
|
||||
const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
let r, g, b
|
||||
h /= 360;
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
||||
};
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
|
||||
'#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6',
|
||||
'#84cc16', '#f97316', '#6366f1', '#a855f7',
|
||||
]
|
||||
'#3b82f6',
|
||||
'#ef4444',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#14b8a6',
|
||||
'#84cc16',
|
||||
'#f97316',
|
||||
'#6366f1',
|
||||
'#a855f7',
|
||||
];
|
||||
|
||||
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [hsl, setHsl] = useState(() => {
|
||||
const rgb = hexToRgb(value)
|
||||
return rgbToHsl(rgb.r, rgb.g, rgb.b)
|
||||
})
|
||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false })
|
||||
const rgb = hexToRgb(value);
|
||||
return rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
});
|
||||
const [pickerPosition, setPickerPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
openUp: boolean;
|
||||
}>({ top: 0, left: 0, openUp: false });
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isTelegram = useMemo(() => isTelegramWebApp(), [])
|
||||
const isTelegram = useMemo(() => isTelegramWebApp(), []);
|
||||
|
||||
// Sync with external value
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
const rgb = hexToRgb(value)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
}, [value])
|
||||
setLocalValue(value);
|
||||
const rgb = hexToRgb(value);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
}, [value]);
|
||||
|
||||
// Calculate picker position
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return
|
||||
if (!buttonRef.current) return;
|
||||
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const pickerHeight = 320
|
||||
const pickerWidth = 280
|
||||
const padding = 12
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const pickerHeight = 320;
|
||||
const pickerWidth = 280;
|
||||
const padding = 12;
|
||||
|
||||
// Check if there's space below
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow;
|
||||
|
||||
// Calculate left position (ensure it stays in viewport)
|
||||
let left = rect.left
|
||||
let left = rect.left;
|
||||
if (left + pickerWidth > window.innerWidth - padding) {
|
||||
left = window.innerWidth - pickerWidth - padding
|
||||
left = window.innerWidth - pickerWidth - padding;
|
||||
}
|
||||
if (left < padding) left = padding
|
||||
if (left < padding) left = padding;
|
||||
|
||||
setPickerPosition({
|
||||
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
|
||||
left,
|
||||
openUp
|
||||
})
|
||||
}, [])
|
||||
openUp,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Open picker
|
||||
const handleOpen = useCallback(() => {
|
||||
if (disabled) return
|
||||
updatePosition()
|
||||
setIsOpen(true)
|
||||
}, [disabled, updatePosition])
|
||||
if (disabled) return;
|
||||
updatePosition();
|
||||
setIsOpen(true);
|
||||
}, [disabled, updatePosition]);
|
||||
|
||||
// Close picker
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
pickerRef.current && !pickerRef.current.contains(e.target as Node) &&
|
||||
buttonRef.current && !buttonRef.current.contains(e.target as Node)
|
||||
pickerRef.current &&
|
||||
!pickerRef.current.contains(e.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
handleClose()
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => handleClose()
|
||||
const handleResize = () => updatePosition()
|
||||
const handleScroll = () => handleClose();
|
||||
const handleResize = () => updatePosition();
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('touchstart', handleClickOutside as EventListener)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchstart', handleClickOutside as EventListener);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('touchstart', handleClickOutside as EventListener)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [isOpen, handleClose, updatePosition])
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchstart', handleClickOutside as EventListener);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [isOpen, handleClose, updatePosition]);
|
||||
|
||||
// Update color from HSL
|
||||
const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => {
|
||||
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l)
|
||||
const hex = rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
setHsl(newHsl)
|
||||
setLocalValue(hex)
|
||||
onChange(hex)
|
||||
}, [onChange])
|
||||
const updateColorFromHsl = useCallback(
|
||||
(newHsl: { h: number; s: number; l: number }) => {
|
||||
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
|
||||
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||
setHsl(newHsl);
|
||||
setLocalValue(hex);
|
||||
onChange(hex);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle hue change
|
||||
const handleHueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) })
|
||||
}, [hsl, updateColorFromHsl])
|
||||
const handleHueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) });
|
||||
},
|
||||
[hsl, updateColorFromHsl],
|
||||
);
|
||||
|
||||
// Handle saturation change
|
||||
const handleSaturationChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) })
|
||||
}, [hsl, updateColorFromHsl])
|
||||
const handleSaturationChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) });
|
||||
},
|
||||
[hsl, updateColorFromHsl],
|
||||
);
|
||||
|
||||
// Handle lightness change
|
||||
const handleLightnessChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) })
|
||||
}, [hsl, updateColorFromHsl])
|
||||
const handleLightnessChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) });
|
||||
},
|
||||
[hsl, updateColorFromHsl],
|
||||
);
|
||||
|
||||
// Handle native color input
|
||||
const handleColorInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = e.target.value
|
||||
setLocalValue(newColor)
|
||||
const rgb = hexToRgb(newColor)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
onChange(newColor)
|
||||
}, [onChange])
|
||||
const handleColorInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = e.target.value;
|
||||
setLocalValue(newColor);
|
||||
const rgb = hexToRgb(newColor);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
onChange(newColor);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle hex input
|
||||
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let newValue = e.target.value
|
||||
if (newValue && !newValue.startsWith('#')) {
|
||||
newValue = '#' + newValue
|
||||
}
|
||||
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
|
||||
setLocalValue(newValue)
|
||||
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
|
||||
const rgb = hexToRgb(newValue)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
onChange(newValue)
|
||||
const handleHexInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let newValue = e.target.value;
|
||||
if (newValue && !newValue.startsWith('#')) {
|
||||
newValue = '#' + newValue;
|
||||
}
|
||||
}
|
||||
}, [onChange])
|
||||
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
|
||||
setLocalValue(newValue);
|
||||
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
|
||||
const rgb = hexToRgb(newValue);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle preset click
|
||||
const handlePresetClick = useCallback((color: string) => {
|
||||
setLocalValue(color)
|
||||
const rgb = hexToRgb(color)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
onChange(color)
|
||||
handleClose()
|
||||
}, [onChange, handleClose])
|
||||
const handlePresetClick = useCallback(
|
||||
(color: string) => {
|
||||
setLocalValue(color);
|
||||
const rgb = hexToRgb(color);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
onChange(color);
|
||||
handleClose();
|
||||
},
|
||||
[onChange, handleClose],
|
||||
);
|
||||
|
||||
// Picker content
|
||||
const pickerContent = isOpen ? (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="fixed z-[9999] w-[280px] bg-dark-900 rounded-2xl border border-dark-700 shadow-2xl overflow-hidden"
|
||||
className="fixed z-[9999] w-[280px] overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
|
||||
style={{
|
||||
top: pickerPosition.top,
|
||||
left: pickerPosition.left,
|
||||
@@ -240,13 +288,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color preview header */}
|
||||
<div
|
||||
className="h-16 w-full"
|
||||
style={{ backgroundColor: localValue || '#000000' }}
|
||||
/>
|
||||
<div className="h-16 w-full" style={{ backgroundColor: localValue || '#000000' }} />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Hue slider */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -259,9 +304,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
max="360"
|
||||
value={hsl.h}
|
||||
onChange={handleHueChange}
|
||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
||||
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
|
||||
background:
|
||||
'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -278,7 +324,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
max="100"
|
||||
value={hsl.s}
|
||||
onChange={handleSaturationChange}
|
||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
||||
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
|
||||
}}
|
||||
@@ -297,7 +343,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
max="100"
|
||||
value={hsl.l}
|
||||
onChange={handleLightnessChange}
|
||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
||||
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,
|
||||
}}
|
||||
@@ -311,21 +357,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleHexInputChange}
|
||||
className="flex-1 h-9 px-3 text-sm font-mono uppercase bg-dark-800 border border-dark-700 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
className="h-9 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="pt-2 border-t border-dark-700">
|
||||
<span className="text-xs font-medium text-dark-400 block mb-2">Presets</span>
|
||||
<div className="border-t border-dark-700 pt-2">
|
||||
<span className="mb-2 block text-xs font-medium text-dark-400">Presets</span>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PRESET_COLORS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className={`w-full aspect-square rounded-lg transition-transform hover:scale-110 active:scale-95 ${
|
||||
className={`aspect-square w-full rounded-lg transition-transform hover:scale-110 active:scale-95 ${
|
||||
localValue.toLowerCase() === preset.toLowerCase()
|
||||
? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900'
|
||||
: ''
|
||||
@@ -338,12 +384,12 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 overflow-hidden">
|
||||
<label className="block text-sm font-medium text-dark-200 mb-1 truncate">{label}</label>
|
||||
{description && <p className="text-xs text-dark-500 mb-2 truncate">{description}</p>}
|
||||
<label className="mb-1 block truncate text-sm font-medium text-dark-200">{label}</label>
|
||||
{description && <p className="mb-2 truncate text-xs text-dark-500">{description}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Color preview button */}
|
||||
@@ -352,7 +398,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className="w-10 h-10 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ backgroundColor: localValue || '#000000' }}
|
||||
title={localValue}
|
||||
/>
|
||||
@@ -363,7 +409,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
value={localValue}
|
||||
onChange={handleHexInputChange}
|
||||
disabled={disabled}
|
||||
className="flex-1 min-w-0 h-10 px-2 font-mono text-sm uppercase bg-dark-800 border border-dark-700 rounded-xl text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50"
|
||||
className="h-10 min-w-0 flex-1 rounded-xl border border-dark-700 bg-dark-800 px-2 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50"
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
/>
|
||||
@@ -383,11 +429,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title="System color picker"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
@@ -397,5 +453,5 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
{/* Render picker in portal */}
|
||||
{typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { balanceApi } from '../api/balance'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import TopUpModal from './TopUpModal'
|
||||
import type { PaymentMethod } from '../types'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { balanceApi } from '../api/balance';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import TopUpModal from './TopUpModal';
|
||||
import type { PaymentMethod } from '../types';
|
||||
|
||||
interface InsufficientBalancePromptProps {
|
||||
/** Amount missing in kopeks */
|
||||
missingAmountKopeks: number
|
||||
missingAmountKopeks: number;
|
||||
/** Optional custom message */
|
||||
message?: string
|
||||
message?: string;
|
||||
/** Compact mode for inline use */
|
||||
compact?: boolean
|
||||
compact?: boolean;
|
||||
/** Additional className */
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InsufficientBalancePrompt({
|
||||
@@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({
|
||||
compact = false,
|
||||
className = '',
|
||||
}: InsufficientBalancePromptProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol } = useCurrency()
|
||||
const [showMethodSelect, setShowMethodSelect] = useState(false)
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const [showMethodSelect, setShowMethodSelect] = useState(false);
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: balanceApi.getPaymentMethods,
|
||||
enabled: showMethodSelect,
|
||||
})
|
||||
});
|
||||
|
||||
const missingRubles = missingAmountKopeks / 100
|
||||
const displayAmount = formatAmount(missingRubles)
|
||||
const missingRubles = missingAmountKopeks / 100;
|
||||
const displayAmount = formatAmount(missingRubles);
|
||||
|
||||
const handleMethodSelect = (method: PaymentMethod) => {
|
||||
setSelectedMethod(method)
|
||||
setShowMethodSelect(false)
|
||||
}
|
||||
setSelectedMethod(method);
|
||||
setShowMethodSelect(false);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-3 p-3 bg-error-500/10 border border-error-500/30 rounded-xl ${className}`}>
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-error-400">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span>
|
||||
{message || t('balance.insufficientFunds')}:{' '}
|
||||
<span className="font-semibold">
|
||||
{displayAmount} {currencySymbol}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodSelect(true)}
|
||||
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap"
|
||||
className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
|
||||
>
|
||||
{t('balance.topUp')}
|
||||
</button>
|
||||
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`p-4 bg-gradient-to-br from-error-500/10 to-warning-500/5 border border-error-500/30 rounded-xl ${className}`}>
|
||||
<div
|
||||
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-error-400 font-medium mb-1">
|
||||
{t('balance.insufficientFunds')}
|
||||
</div>
|
||||
<div className="text-dark-300 text-sm">
|
||||
{message || t('balance.topUpToComplete')}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
|
||||
<div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{t('balance.missing')}: <span className="text-error-400">{displayAmount} {currencySymbol}</span>
|
||||
{t('balance.missing')}:{' '}
|
||||
<span className="text-error-400">
|
||||
{displayAmount} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodSelect(true)}
|
||||
className="btn-primary w-full mt-4 py-2.5 flex items-center justify-center gap-2"
|
||||
className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{t('balance.topUpBalance')}
|
||||
@@ -131,80 +163,115 @@ export default function InsufficientBalancePrompt({
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentMethodModalProps {
|
||||
paymentMethods: PaymentMethod[] | undefined
|
||||
onSelect: (method: PaymentMethod) => void
|
||||
onClose: () => void
|
||||
paymentMethods: PaymentMethod[] | undefined;
|
||||
onSelect: (method: PaymentMethod) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol } = useCurrency()
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
|
||||
<div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||
<div className="flex items-center justify-between bg-dark-800/50 px-4 py-3">
|
||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{!paymentMethods ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-6 text-dark-400 text-sm">
|
||||
<div className="py-6 text-center text-sm text-dark-400">
|
||||
{t('balance.noPaymentMethods')}
|
||||
</div>
|
||||
) : (
|
||||
paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
disabled={!method.is_available}
|
||||
onClick={() => method.is_available && onSelect(method)}
|
||||
className={`w-full p-3 rounded-xl text-left flex items-center gap-3 ${
|
||||
className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
|
||||
method.is_available
|
||||
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
|
||||
: 'bg-dark-800/50 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-accent-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
|
||||
<svg
|
||||
className="h-4 w-4 text-accent-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{translatedName || method.name}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} – {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
<svg
|
||||
className="h-4 w-4 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const languages = [
|
||||
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
|
||||
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
|
||||
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
|
||||
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
|
||||
]
|
||||
];
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { i18n } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0]
|
||||
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0];
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const changeLanguage = (code: string) => {
|
||||
i18n.changeLanguage(code)
|
||||
i18n.changeLanguage(code);
|
||||
// Set document direction for RTL languages
|
||||
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Set initial direction on mount
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'
|
||||
}, [i18n.language])
|
||||
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
|
||||
}, [i18n.language]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 hover:bg-dark-700 transition-all text-sm"
|
||||
className="flex items-center gap-1.5 rounded-xl border border-dark-700/50 bg-dark-800/50 px-2.5 py-2 text-sm transition-all hover:border-dark-600 hover:bg-dark-700"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span>{currentLang.flag}</span>
|
||||
<span className="font-medium text-dark-200">{currentLang.name}</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -57,12 +57,12 @@ export default function LanguageSwitcher() {
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-dark-800 rounded-xl shadow-lg border border-dark-700/50 py-1 z-50 animate-fade-in">
|
||||
<div className="absolute right-0 z-50 mt-2 w-40 animate-fade-in rounded-xl border border-dark-700/50 bg-dark-800 py-1 shadow-lg">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
lang.code === i18n.language
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-300 hover:bg-dark-700/50'
|
||||
@@ -75,5 +75,5 @@ export default function LanguageSwitcher() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,146 +1,147 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OnboardingStep {
|
||||
target: string // data-onboarding attribute value
|
||||
title: string
|
||||
description: string
|
||||
placement: 'top' | 'bottom' | 'left' | 'right'
|
||||
target: string; // data-onboarding attribute value
|
||||
title: string;
|
||||
description: string;
|
||||
placement: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
interface OnboardingProps {
|
||||
steps: OnboardingStep[]
|
||||
onComplete: () => void
|
||||
onSkip: () => void
|
||||
steps: OnboardingStep[];
|
||||
onComplete: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'onboarding_completed'
|
||||
const STORAGE_KEY = 'onboarding_completed';
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useOnboarding() {
|
||||
const [isCompleted, setIsCompleted] = useState(() => {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
})
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
});
|
||||
|
||||
const complete = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true')
|
||||
setIsCompleted(true)
|
||||
}, [])
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setIsCompleted(true);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
setIsCompleted(false)
|
||||
}, [])
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setIsCompleted(false);
|
||||
}, []);
|
||||
|
||||
return { isCompleted, complete, reset }
|
||||
return { isCompleted, complete, reset };
|
||||
}
|
||||
|
||||
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
|
||||
const { t } = useTranslation()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const step = steps[currentStep]
|
||||
const step = steps[currentStep];
|
||||
|
||||
// Find and highlight target element
|
||||
useEffect(() => {
|
||||
const findTarget = () => {
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
setTargetRect(rect)
|
||||
const rect = target.getBoundingClientRect();
|
||||
setTargetRect(rect);
|
||||
|
||||
// Scroll element into view if needed
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Delay visibility for smooth animation
|
||||
setTimeout(() => setIsVisible(true), 100)
|
||||
setTimeout(() => setIsVisible(true), 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setIsVisible(false)
|
||||
const timer = setTimeout(findTarget, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [step.target])
|
||||
setIsVisible(false);
|
||||
const timer = setTimeout(findTarget, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [step.target]);
|
||||
|
||||
// Recalculate position on resize/scroll
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||
if (target) {
|
||||
setTargetRect(target.getBoundingClientRect())
|
||||
setTargetRect(target.getBoundingClientRect());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [step.target])
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [step.target]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
onComplete()
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip()
|
||||
}
|
||||
onSkip();
|
||||
};
|
||||
|
||||
// Calculate tooltip position
|
||||
const getTooltipStyle = (): React.CSSProperties => {
|
||||
if (!targetRect) return { opacity: 0 }
|
||||
if (!targetRect) return { opacity: 0 };
|
||||
|
||||
const padding = 16
|
||||
const tooltipWidth = 320
|
||||
const tooltipHeight = tooltipRef.current?.offsetHeight || 150
|
||||
const padding = 16;
|
||||
const tooltipWidth = 320;
|
||||
const tooltipHeight = tooltipRef.current?.offsetHeight || 150;
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (step.placement) {
|
||||
case 'bottom':
|
||||
top = targetRect.bottom + padding
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
|
||||
break
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
||||
break;
|
||||
case 'top':
|
||||
top = targetRect.top - tooltipHeight - padding
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
|
||||
break
|
||||
top = targetRect.top - tooltipHeight - padding;
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
|
||||
left = targetRect.left - tooltipWidth - padding
|
||||
break
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
||||
left = targetRect.left - tooltipWidth - padding;
|
||||
break;
|
||||
case 'right':
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
|
||||
left = targetRect.right + padding
|
||||
break
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
||||
left = targetRect.right + padding;
|
||||
break;
|
||||
}
|
||||
|
||||
// Keep within viewport
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (left < padding) left = padding
|
||||
if (left < padding) left = padding;
|
||||
if (left + tooltipWidth > viewportWidth - padding) {
|
||||
left = viewportWidth - tooltipWidth - padding
|
||||
left = viewportWidth - tooltipWidth - padding;
|
||||
}
|
||||
if (top < padding) top = padding
|
||||
if (top < padding) top = padding;
|
||||
if (top + tooltipHeight > viewportHeight - padding) {
|
||||
top = viewportHeight - tooltipHeight - padding
|
||||
top = viewportHeight - tooltipHeight - padding;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -149,22 +150,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
width: tooltipWidth,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'scale(1)' : 'scale(0.95)',
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Spotlight style
|
||||
const getSpotlightStyle = (): React.CSSProperties => {
|
||||
if (!targetRect) return { opacity: 0 }
|
||||
if (!targetRect) return { opacity: 0 };
|
||||
|
||||
const padding = 8
|
||||
const padding = 8;
|
||||
return {
|
||||
top: targetRect.top - padding,
|
||||
left: targetRect.left - padding,
|
||||
width: targetRect.width + padding * 2,
|
||||
height: targetRect.height + padding * 2,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
||||
@@ -178,7 +179,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
style={getTooltipStyle()}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-1.5 mb-4">
|
||||
<div className="mb-4 flex items-center gap-1.5">
|
||||
{steps.map((s, index) => (
|
||||
<div
|
||||
key={s.target}
|
||||
@@ -186,33 +187,33 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
index === currentStep
|
||||
? 'w-6 bg-accent-500'
|
||||
: index < currentStep
|
||||
? 'w-2 bg-accent-500/50'
|
||||
: 'w-2 bg-dark-700'
|
||||
? 'w-2 bg-accent-500/50'
|
||||
: 'w-2 bg-dark-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-lg font-semibold text-dark-50 mb-2">{step.title}</h3>
|
||||
<p className="text-dark-400 text-sm mb-5">{step.description}</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-50">{step.title}</h3>
|
||||
<p className="mb-5 text-sm text-dark-400">{step.description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-dark-500 hover:text-dark-300 text-sm transition-colors"
|
||||
className="text-sm text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
{t('onboarding.skip', 'Skip')}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{currentStep > 0 && (
|
||||
<button onClick={handlePrev} className="btn-ghost text-sm px-3 py-1.5">
|
||||
<button onClick={handlePrev} className="btn-ghost px-3 py-1.5 text-sm">
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleNext} className="btn-primary text-sm px-4 py-1.5">
|
||||
<button onClick={handleNext} className="btn-primary px-4 py-1.5 text-sm">
|
||||
{currentStep === steps.length - 1
|
||||
? t('onboarding.finish', 'Finish')
|
||||
: t('common.next', 'Next')}
|
||||
@@ -235,6 +236,6 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,111 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { promoApi } from '../api/promo'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { promoApi } from '../api/promo';
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||
let expires: Date
|
||||
let expires: Date;
|
||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||
expires = new Date(expiresAt)
|
||||
expires = new Date(expiresAt);
|
||||
} else {
|
||||
// No timezone - treat as UTC
|
||||
expires = new Date(expiresAt + 'Z')
|
||||
expires = new Date(expiresAt + 'Z');
|
||||
}
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
const diffMs = expires.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return ''
|
||||
if (diffMs <= 0) return '';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}${t('promo.time.days')}`
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}${t('promo.time.days')}`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`
|
||||
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
|
||||
}
|
||||
return `${minutes}${t('promo.time.minutes')}`
|
||||
}
|
||||
return `${minutes}${t('promo.time.minutes')}`;
|
||||
};
|
||||
|
||||
export default function PromoDiscountBadge() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
})
|
||||
});
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Don't render if no active discount
|
||||
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null
|
||||
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleGoToSubscription = () => {
|
||||
setIsOpen(false)
|
||||
navigate('/subscription')
|
||||
}
|
||||
setIsOpen(false);
|
||||
navigate('/subscription');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Badge button */}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="relative flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-success-500/15 hover:bg-success-500/25 transition-all group"
|
||||
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
|
||||
title={t('promo.activeDiscount', 'Active discount')}
|
||||
>
|
||||
<span className="font-bold text-success-400 text-sm">
|
||||
<span className="text-sm font-bold text-success-400">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</span>
|
||||
</button>
|
||||
@@ -101,21 +115,19 @@ export default function PromoDiscountBadge() {
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40 sm:hidden"
|
||||
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="fixed left-4 right-4 top-20 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72 bg-dark-800 rounded-xl shadow-xl border border-dark-700/50 z-50 animate-fade-in overflow-hidden">
|
||||
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4 border-b border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-500/20 flex items-center justify-center text-success-400">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">
|
||||
{t('promo.discountActive')}
|
||||
</div>
|
||||
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</div>
|
||||
@@ -124,23 +136,24 @@ export default function PromoDiscountBadge() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-sm text-dark-300">
|
||||
{t('promo.discountDescription')}
|
||||
</p>
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
|
||||
|
||||
{/* Time remaining */}
|
||||
{timeLeft && (
|
||||
<div className="flex items-center gap-2 text-sm text-dark-400 bg-dark-900/50 px-3 py-2 rounded-lg">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
|
||||
<ClockIcon />
|
||||
<span>{t('promo.expiresIn')}: <span className="text-warning-400 font-medium">{timeLeft}</span></span>
|
||||
<span>
|
||||
{t('promo.expiresIn')}:{' '}
|
||||
<span className="font-medium text-warning-400">{timeLeft}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
<button
|
||||
onClick={handleGoToSubscription}
|
||||
className="w-full btn-primary py-2.5 text-sm font-medium"
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium"
|
||||
>
|
||||
{t('promo.useNow')}
|
||||
</button>
|
||||
@@ -149,5 +162,5 @@ export default function PromoDiscountBadge() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +1,165 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { promoApi, PromoOffer } from '../api/promo'
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { promoApi, PromoOffer } from '../api/promo';
|
||||
|
||||
// Icons
|
||||
const GiftIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
const formatTimeLeft = (expiresAt: string): string => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||
let expires: Date
|
||||
let expires: Date;
|
||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||
expires = new Date(expiresAt)
|
||||
expires = new Date(expiresAt);
|
||||
} else {
|
||||
// No timezone - treat as UTC
|
||||
expires = new Date(expiresAt + 'Z')
|
||||
expires = new Date(expiresAt + 'Z');
|
||||
}
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
const diffMs = expires.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return 'Истекло'
|
||||
if (diffMs <= 0) return 'Истекло';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} дн.`
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} дн.`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}ч ${minutes}м`
|
||||
return `${hours}ч ${minutes}м`;
|
||||
}
|
||||
return `${minutes}м`
|
||||
}
|
||||
return `${minutes}м`;
|
||||
};
|
||||
|
||||
const getOfferIcon = (effectType: string) => {
|
||||
if (effectType === 'test_access') return <ServerIcon />
|
||||
return <SparklesIcon />
|
||||
}
|
||||
if (effectType === 'test_access') return <ServerIcon />;
|
||||
return <SparklesIcon />;
|
||||
};
|
||||
|
||||
const getOfferTitle = (offer: PromoOffer): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
return 'Тестовый доступ'
|
||||
return 'Тестовый доступ';
|
||||
}
|
||||
if (offer.discount_percent) {
|
||||
return `Скидка ${offer.discount_percent}%`
|
||||
return `Скидка ${offer.discount_percent}%`;
|
||||
}
|
||||
return 'Специальное предложение'
|
||||
}
|
||||
return 'Специальное предложение';
|
||||
};
|
||||
|
||||
const getOfferDescription = (offer: PromoOffer): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0
|
||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'
|
||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0;
|
||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
|
||||
}
|
||||
return 'Активируйте скидку на покупку подписки'
|
||||
}
|
||||
return 'Активируйте скидку на покупку подписки';
|
||||
};
|
||||
|
||||
interface PromoOffersSectionProps {
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [claimingId, setClaimingId] = useState<number | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient();
|
||||
const [claimingId, setClaimingId] = useState<number | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Fetch available offers
|
||||
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
||||
queryKey: ['promo-offers'],
|
||||
queryFn: promoApi.getOffers,
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch active discount
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Claim offer mutation
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: promoApi.claimOffer,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
setSuccessMessage(result.message)
|
||||
setClaimingId(null)
|
||||
setTimeout(() => setSuccessMessage(null), 5000)
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
setSuccessMessage(result.message);
|
||||
setClaimingId(null);
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение')
|
||||
setClaimingId(null)
|
||||
setTimeout(() => setErrorMessage(null), 5000)
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
|
||||
setClaimingId(null);
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleClaim = (offerId: number) => {
|
||||
setClaimingId(offerId)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
claimMutation.mutate(offerId)
|
||||
}
|
||||
setClaimingId(offerId);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
claimMutation.mutate(offerId);
|
||||
};
|
||||
|
||||
// Filter unclaimed and active offers
|
||||
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed)
|
||||
const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
|
||||
|
||||
// Don't render if no offers and no active discount
|
||||
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) {
|
||||
return null
|
||||
if (
|
||||
!offersLoading &&
|
||||
availableOffers.length === 0 &&
|
||||
(!activeDiscount || !activeDiscount.is_active)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
||||
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20 text-accent-400">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
Скидка {activeDiscount.discount_percent}% активна
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded">
|
||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
Действует
|
||||
</span>
|
||||
</div>
|
||||
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-success-400">
|
||||
<CheckIcon />
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl">
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-error-400">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
@@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{availableOffers.map((offer) => (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors"
|
||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-orange-400">
|
||||
{getOfferIcon(offer.effect_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
{getOfferTitle(offer)}
|
||||
</h3>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
|
||||
{offer.effect_type === 'test_access' && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded">
|
||||
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||
Тест
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-3">
|
||||
{getOfferDescription(offer)}
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<ClockIcon />
|
||||
@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
<button
|
||||
onClick={() => handleClaim(offer.id)}
|
||||
disabled={claimingId === offer.id}
|
||||
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{claimingId === offer.id ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Активация...</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -246,14 +263,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{offersLoading && (
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" />
|
||||
<div className="h-12 w-12 animate-pulse rounded-xl bg-dark-700" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" />
|
||||
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" />
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,45 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TelegramLoginButtonProps {
|
||||
botUsername: string
|
||||
botUsername: string;
|
||||
}
|
||||
|
||||
export default function TelegramLoginButton({
|
||||
botUsername,
|
||||
}: TelegramLoginButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load widget script
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !botUsername) return
|
||||
if (!containerRef.current || !botUsername) return;
|
||||
|
||||
// Clear previous widget using safe DOM API
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild)
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
|
||||
// Get current URL for redirect
|
||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
|
||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
|
||||
|
||||
// Create script element for Telegram Login Widget
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22'
|
||||
script.setAttribute('data-telegram-login', botUsername)
|
||||
script.setAttribute('data-size', 'large')
|
||||
script.setAttribute('data-radius', '8')
|
||||
script.setAttribute('data-auth-url', redirectUrl)
|
||||
script.setAttribute('data-request-access', 'write')
|
||||
script.async = true
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
||||
script.setAttribute('data-telegram-login', botUsername);
|
||||
script.setAttribute('data-size', 'large');
|
||||
script.setAttribute('data-radius', '8');
|
||||
script.setAttribute('data-auth-url', redirectUrl);
|
||||
script.setAttribute('data-request-access', 'write');
|
||||
script.async = true;
|
||||
|
||||
containerRef.current.appendChild(script)
|
||||
}, [botUsername])
|
||||
containerRef.current.appendChild(script);
|
||||
}, [botUsername]);
|
||||
|
||||
if (!botUsername || botUsername === 'your_bot') {
|
||||
return (
|
||||
<div className="text-center text-gray-500 text-sm py-4">
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
{t('auth.telegramNotConfigured')}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -51,19 +49,19 @@ export default function TelegramLoginButton({
|
||||
|
||||
{/* Fallback link for mobile */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p>
|
||||
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
|
||||
<a
|
||||
href={`https://t.me/${botUsername}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-sm text-telegram-blue hover:underline"
|
||||
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
<svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
@{botUsername}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,122 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'
|
||||
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'
|
||||
import { applyThemeColors } from '../hooks/useThemeColors'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
|
||||
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
|
||||
import { applyThemeColors } from '../hooks/useThemeColors';
|
||||
|
||||
interface ThemeBentoPickerProps {
|
||||
currentColors: ThemeColors
|
||||
onColorsChange: (colors: ThemeColors) => void
|
||||
onSave: () => void
|
||||
isSaving: boolean
|
||||
currentColors: ThemeColors;
|
||||
onColorsChange: (colors: ThemeColors) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SunIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const StatusIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const PaletteIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
preset: ColorPreset
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
preset: ColorPreset;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { i18n } = useTranslation()
|
||||
const isRu = i18n.language === 'ru'
|
||||
const { i18n } = useTranslation();
|
||||
const isRu = i18n.language === 'ru';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${
|
||||
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
|
||||
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
|
||||
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
|
||||
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0"
|
||||
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
|
||||
style={{ backgroundColor: preset.preview.background }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md"
|
||||
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
|
||||
style={{ backgroundColor: preset.preview.accent }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60"
|
||||
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
|
||||
style={{ backgroundColor: preset.preview.text }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-5 right-2 w-7 h-1 rounded-full opacity-40"
|
||||
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
|
||||
style={{ backgroundColor: preset.preview.text }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-xs font-semibold text-dark-100 truncate">
|
||||
<h4 className="truncate text-xs font-semibold text-dark-100">
|
||||
{isRu ? preset.nameRu : preset.name}
|
||||
</h4>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function HSLSlider({
|
||||
@@ -111,18 +127,18 @@ function HSLSlider({
|
||||
gradient,
|
||||
suffix = '',
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
max: number
|
||||
gradient: string
|
||||
suffix?: string
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
max: number;
|
||||
gradient: string;
|
||||
suffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-dark-300">{label}</label>
|
||||
<span className="text-xs text-dark-500 font-mono tabular-nums">
|
||||
<span className="font-mono text-xs tabular-nums text-dark-500">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
@@ -133,11 +149,11 @@ function HSLSlider({
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||
className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
|
||||
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{ background: gradient }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CompactColorInput({
|
||||
@@ -145,45 +161,45 @@ function CompactColorInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (color: string) => void
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
let formatted = newValue.toUpperCase()
|
||||
let formatted = newValue.toUpperCase();
|
||||
if (!formatted.startsWith('#')) {
|
||||
formatted = '#' + formatted
|
||||
formatted = '#' + formatted;
|
||||
}
|
||||
setLocalValue(formatted)
|
||||
setLocalValue(formatted);
|
||||
if (isValidHex(formatted)) {
|
||||
onChange(formatted)
|
||||
onChange(formatted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false)
|
||||
setIsEditing(false);
|
||||
if (!isValidHex(localValue)) {
|
||||
setLocalValue(value)
|
||||
setLocalValue(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group">
|
||||
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105"
|
||||
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
|
||||
{label}
|
||||
</label>
|
||||
{isEditing ? (
|
||||
@@ -194,21 +210,21 @@ function CompactColorInput({
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||
autoFocus
|
||||
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none"
|
||||
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
|
||||
maxLength={7}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left"
|
||||
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
@@ -219,24 +235,24 @@ function CollapsibleSection({
|
||||
children,
|
||||
badge,
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
children: React.ReactNode
|
||||
badge?: string
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden">
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors"
|
||||
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="text-dark-400">{icon}</div>
|
||||
<span className="text-sm font-medium text-dark-200">{title}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono">
|
||||
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -254,11 +270,11 @@ function CollapsibleSection({
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4 pt-1 border-t border-dark-700/30">{children}</div>
|
||||
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeBentoPicker({
|
||||
@@ -267,67 +283,67 @@ export function ThemeBentoPicker({
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ThemeBentoPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent))
|
||||
const [hexInput, setHexInput] = useState(currentColors.accent)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
|
||||
const [hexInput, setHexInput] = useState(currentColors.accent);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const [isPresetsOpen, setIsPresetsOpen] = useState(false)
|
||||
const [isAccentOpen, setIsAccentOpen] = useState(false)
|
||||
const [isDarkOpen, setIsDarkOpen] = useState(false)
|
||||
const [isLightOpen, setIsLightOpen] = useState(false)
|
||||
const [isStatusOpen, setIsStatusOpen] = useState(false)
|
||||
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
|
||||
const [isAccentOpen, setIsAccentOpen] = useState(false);
|
||||
const [isDarkOpen, setIsDarkOpen] = useState(false);
|
||||
const [isLightOpen, setIsLightOpen] = useState(false);
|
||||
const [isStatusOpen, setIsStatusOpen] = useState(false);
|
||||
|
||||
const selectedPresetId = useMemo(() => {
|
||||
const match = COLOR_PRESETS.find(
|
||||
(p) =>
|
||||
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
|
||||
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
|
||||
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase()
|
||||
)
|
||||
return match?.id ?? null
|
||||
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground])
|
||||
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
|
||||
);
|
||||
return match?.id ?? null;
|
||||
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
|
||||
|
||||
useEffect(() => {
|
||||
setHsl(hexToHsl(currentColors.accent))
|
||||
setHexInput(currentColors.accent)
|
||||
}, [currentColors.accent])
|
||||
setHsl(hexToHsl(currentColors.accent));
|
||||
setHexInput(currentColors.accent);
|
||||
}, [currentColors.accent]);
|
||||
|
||||
const updateColor = useCallback(
|
||||
(key: keyof ThemeColors, value: string) => {
|
||||
const newColors = { ...currentColors, [key]: value }
|
||||
onColorsChange(newColors)
|
||||
applyThemeColors(newColors)
|
||||
setHasChanges(true)
|
||||
const newColors = { ...currentColors, [key]: value };
|
||||
onColorsChange(newColors);
|
||||
applyThemeColors(newColors);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[currentColors, onColorsChange]
|
||||
)
|
||||
[currentColors, onColorsChange],
|
||||
);
|
||||
|
||||
const updateAccentFromHsl = useCallback(
|
||||
(newHsl: HSLColor) => {
|
||||
setHsl(newHsl)
|
||||
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l)
|
||||
setHexInput(newHex)
|
||||
updateColor('accent', newHex)
|
||||
setHsl(newHsl);
|
||||
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
|
||||
setHexInput(newHex);
|
||||
updateColor('accent', newHex);
|
||||
},
|
||||
[updateColor]
|
||||
)
|
||||
[updateColor],
|
||||
);
|
||||
|
||||
const handleHexInputChange = (value: string) => {
|
||||
setHexInput(value)
|
||||
setHexInput(value);
|
||||
if (isValidHex(value)) {
|
||||
const newHsl = hexToHsl(value)
|
||||
setHsl(newHsl)
|
||||
updateColor('accent', value)
|
||||
const newHsl = hexToHsl(value);
|
||||
setHsl(newHsl);
|
||||
updateColor('accent', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: ColorPreset) => {
|
||||
onColorsChange(preset.colors)
|
||||
applyThemeColors(preset.colors)
|
||||
setHasChanges(true)
|
||||
}
|
||||
onColorsChange(preset.colors);
|
||||
applyThemeColors(preset.colors);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const hueGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
@@ -338,23 +354,23 @@ export function ThemeBentoPicker({
|
||||
hsl(240, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(300, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(360, ${hsl.s}%, ${hsl.l}%)
|
||||
)`
|
||||
}, [hsl.s, hsl.l])
|
||||
)`;
|
||||
}, [hsl.s, hsl.l]);
|
||||
|
||||
const saturationGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(${hsl.h}, 0%, ${hsl.l}%),
|
||||
hsl(${hsl.h}, 100%, ${hsl.l}%)
|
||||
)`
|
||||
}, [hsl.h, hsl.l])
|
||||
)`;
|
||||
}, [hsl.h, hsl.l]);
|
||||
|
||||
const lightnessGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(${hsl.h}, ${hsl.s}%, 0%),
|
||||
hsl(${hsl.h}, ${hsl.s}%, 50%),
|
||||
hsl(${hsl.h}, ${hsl.s}%, 100%)
|
||||
)`
|
||||
}, [hsl.h, hsl.s])
|
||||
)`;
|
||||
}, [hsl.h, hsl.s]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -365,9 +381,13 @@ export function ThemeBentoPicker({
|
||||
isOpen={isPresetsOpen}
|
||||
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
|
||||
>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-1">
|
||||
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
|
||||
{COLOR_PRESETS.map((preset, index) => (
|
||||
<div key={preset.id} className="min-h-[100px]" style={{ '--stagger': index } as React.CSSProperties}>
|
||||
<div
|
||||
key={preset.id}
|
||||
className="min-h-[100px]"
|
||||
style={{ '--stagger': index } as React.CSSProperties}
|
||||
>
|
||||
<PresetCard
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
@@ -379,7 +399,7 @@ export function ThemeBentoPicker({
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||
{t('admin.theme.customizeColors', 'Customize Colors')}
|
||||
</h3>
|
||||
|
||||
@@ -392,13 +412,13 @@ export function ThemeBentoPicker({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="w-full h-14 rounded-xl shadow-inner relative overflow-hidden"
|
||||
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
<div className="absolute bottom-2 right-3 text-white/80 text-xs font-mono drop-shadow">
|
||||
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
|
||||
{hexInput.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -431,7 +451,7 @@ export function ThemeBentoPicker({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-dark-300 mb-1.5 block">
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.theme.hexCode', 'HEX Code')}
|
||||
</label>
|
||||
<input
|
||||
@@ -440,7 +460,7 @@ export function ThemeBentoPicker({
|
||||
onChange={(e) => handleHexInputChange(e.target.value)}
|
||||
placeholder="#3b82f6"
|
||||
maxLength={7}
|
||||
className="input w-full text-sm font-mono uppercase"
|
||||
className="input w-full font-mono text-sm uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,8 +552,8 @@ export function ThemeBentoPicker({
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4">
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
|
||||
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
|
||||
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||
{t('theme.preview', 'Preview')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -548,12 +568,12 @@ export function ThemeBentoPicker({
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end animate-fade-in">
|
||||
<div className="flex animate-fade-in justify-end">
|
||||
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
||||
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,252 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ticketNotificationsApi } from '../api/ticketNotifications'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { useToast } from './Toast'
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
||||
import type { TicketNotification } from '../types'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ticketNotificationsApi } from '../api/ticketNotifications';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useToast } from './Toast';
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import type { TicketNotification } from '../types';
|
||||
|
||||
const BellIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
interface TicketNotificationBellProps {
|
||||
isAdmin?: boolean
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { showToast } = useToast()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { showToast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||
|
||||
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
|
||||
const dropdownTop = isFullscreen
|
||||
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
|
||||
: 64 // default header height
|
||||
: 64; // default header height
|
||||
|
||||
// Show toast for WebSocket notification
|
||||
const showWSNotificationToast = useCallback((message: WSMessage) => {
|
||||
const isNewTicket = message.type === 'ticket.new'
|
||||
const isAdminReply = message.type === 'ticket.admin_reply'
|
||||
const isUserReply = message.type === 'ticket.user_reply'
|
||||
const showWSNotificationToast = useCallback(
|
||||
(message: WSMessage) => {
|
||||
const isNewTicket = message.type === 'ticket.new';
|
||||
const isAdminReply = message.type === 'ticket.admin_reply';
|
||||
const isUserReply = message.type === 'ticket.user_reply';
|
||||
|
||||
const icon = isNewTicket ? (
|
||||
<span className="text-lg">🎫</span>
|
||||
) : isAdminReply ? (
|
||||
<span className="text-lg">💬</span>
|
||||
) : (
|
||||
<span className="text-lg">📨</span>
|
||||
)
|
||||
const icon = isNewTicket ? (
|
||||
<span className="text-lg">🎫</span>
|
||||
) : isAdminReply ? (
|
||||
<span className="text-lg">💬</span>
|
||||
) : (
|
||||
<span className="text-lg">📨</span>
|
||||
);
|
||||
|
||||
const ticketTitle = message.title || ''
|
||||
const ticketTitle = message.title || '';
|
||||
|
||||
let toastTitle: string
|
||||
let toastMessage: string
|
||||
let toastTitle: string;
|
||||
let toastMessage: string;
|
||||
|
||||
if (isNewTicket) {
|
||||
toastTitle = t('notifications.newTicketTitle', 'New Ticket')
|
||||
toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle })
|
||||
} else if (isUserReply) {
|
||||
toastTitle = t('notifications.newUserReplyTitle', 'User Reply')
|
||||
toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle })
|
||||
} else {
|
||||
toastTitle = t('notifications.newReplyTitle', 'New Reply')
|
||||
toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle })
|
||||
}
|
||||
if (isNewTicket) {
|
||||
toastTitle = t('notifications.newTicketTitle', 'New Ticket');
|
||||
toastMessage =
|
||||
message.message ||
|
||||
t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle });
|
||||
} else if (isUserReply) {
|
||||
toastTitle = t('notifications.newUserReplyTitle', 'User Reply');
|
||||
toastMessage =
|
||||
message.message ||
|
||||
t('notifications.newUserReply', 'User replied in ticket: {{title}}', {
|
||||
title: ticketTitle,
|
||||
});
|
||||
} else {
|
||||
toastTitle = t('notifications.newReplyTitle', 'New Reply');
|
||||
toastMessage =
|
||||
message.message ||
|
||||
t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle });
|
||||
}
|
||||
|
||||
showToast({
|
||||
type: 'info',
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
icon,
|
||||
onClick: () => {
|
||||
navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`)
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
}, [showToast, navigate, isAdmin, t])
|
||||
showToast({
|
||||
type: 'info',
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
icon,
|
||||
onClick: () => {
|
||||
navigate(
|
||||
isAdmin
|
||||
? `/admin/tickets?ticket=${message.ticket_id}`
|
||||
: `/support?ticket=${message.ticket_id}`,
|
||||
);
|
||||
},
|
||||
duration: 8000,
|
||||
});
|
||||
},
|
||||
[showToast, navigate, isAdmin, t],
|
||||
);
|
||||
|
||||
// Handle WebSocket message
|
||||
const handleWSMessage = useCallback((message: WSMessage) => {
|
||||
// Check if this notification is relevant for this user type
|
||||
const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply'
|
||||
const isUserNotification = message.type === 'ticket.admin_reply'
|
||||
const handleWSMessage = useCallback(
|
||||
(message: WSMessage) => {
|
||||
// Check if this notification is relevant for this user type
|
||||
const isAdminNotification =
|
||||
message.type === 'ticket.new' || message.type === 'ticket.user_reply';
|
||||
const isUserNotification = message.type === 'ticket.admin_reply';
|
||||
|
||||
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
|
||||
// Show toast
|
||||
showWSNotificationToast(message)
|
||||
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
|
||||
// Show toast
|
||||
showWSNotificationToast(message);
|
||||
|
||||
// Invalidate queries to refresh count and list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count']
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications']
|
||||
})
|
||||
}
|
||||
}, [isAdmin, showWSNotificationToast, queryClient])
|
||||
// Invalidate queries to refresh count and list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
});
|
||||
}
|
||||
},
|
||||
[isAdmin, showWSNotificationToast, queryClient],
|
||||
);
|
||||
|
||||
// WebSocket connection
|
||||
useWebSocket({
|
||||
onMessage: handleWSMessage,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch unread count (with slower polling as fallback when WS disconnects)
|
||||
const { data: unreadData } = useQuery({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount,
|
||||
queryFn: isAdmin
|
||||
? ticketNotificationsApi.getAdminUnreadCount
|
||||
: ticketNotificationsApi.getUnreadCount,
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60000, // Poll every 60 seconds as fallback
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch notifications when dropdown is open
|
||||
const { data: notificationsData, isLoading } = useQuery({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
queryFn: () => isAdmin
|
||||
? ticketNotificationsApi.getAdminNotifications(false, 10)
|
||||
: ticketNotificationsApi.getNotifications(false, 10),
|
||||
queryFn: () =>
|
||||
isAdmin
|
||||
? ticketNotificationsApi.getAdminNotifications(false, 10)
|
||||
: ticketNotificationsApi.getNotifications(false, 10),
|
||||
enabled: isAuthenticated && isOpen,
|
||||
staleTime: 5000,
|
||||
})
|
||||
});
|
||||
|
||||
// Mark all as read mutation
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead,
|
||||
mutationFn: isAdmin
|
||||
? ticketNotificationsApi.markAllAdminAsRead
|
||||
: ticketNotificationsApi.markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Mark single as read mutation
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead,
|
||||
mutationFn: isAdmin
|
||||
? ticketNotificationsApi.markAdminAsRead
|
||||
: ticketNotificationsApi.markAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = (notification: TicketNotification) => {
|
||||
if (!notification.is_read) {
|
||||
markReadMutation.mutate(notification.id)
|
||||
markReadMutation.mutate(notification.id);
|
||||
}
|
||||
setIsOpen(false)
|
||||
navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`)
|
||||
}
|
||||
setIsOpen(false);
|
||||
navigate(
|
||||
isAdmin
|
||||
? `/admin/tickets?ticket=${notification.ticket_id}`
|
||||
: `/support?ticket=${notification.ticket_id}`,
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return t('notifications.justNow', 'Just now')
|
||||
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins })
|
||||
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours })
|
||||
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays })
|
||||
}
|
||||
if (diffMins < 1) return t('notifications.justNow', 'Just now');
|
||||
if (diffMins < 60)
|
||||
return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins });
|
||||
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours });
|
||||
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays });
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_ticket':
|
||||
return <span className="text-lg">🎫</span>
|
||||
return <span className="text-lg">🎫</span>;
|
||||
case 'admin_reply':
|
||||
return <span className="text-lg">💬</span>
|
||||
return <span className="text-lg">💬</span>;
|
||||
case 'user_reply':
|
||||
return <span className="text-lg">📨</span>
|
||||
return <span className="text-lg">📨</span>;
|
||||
default:
|
||||
return <span className="text-lg">🔔</span>
|
||||
return <span className="text-lg">🔔</span>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unreadCount = unreadData?.unread_count || 0
|
||||
const unreadCount = unreadData?.unread_count || 0;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 rounded-xl transition-all duration-200 bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50 text-dark-400 hover:text-accent-400"
|
||||
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
|
||||
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full px-1 animate-scale-in-bounce">
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] animate-scale-in-bounce items-center justify-center rounded-full bg-error-500 px-1 text-xs font-bold text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -212,13 +255,13 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed sm:absolute sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in ${
|
||||
className={`fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96 ${
|
||||
!isFullscreen ? 'top-16' : ''
|
||||
}`}
|
||||
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-dark-100">
|
||||
{t('notifications.ticketNotifications', 'Ticket Notifications')}
|
||||
</h3>
|
||||
@@ -226,7 +269,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 text-xs text-accent-400 transition-colors hover:text-accent-300 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('notifications.markAllRead', 'Mark all read')}
|
||||
@@ -238,32 +281,34 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-dark-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"></div>
|
||||
</div>
|
||||
) : notificationsData?.items && notificationsData.items.length > 0 ? (
|
||||
notificationsData.items.map((notification: TicketNotification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-dark-800/50 last:border-b-0 hover:bg-dark-800/50 transition-all duration-200 ${
|
||||
className={`w-full border-b border-dark-800/50 px-4 py-3 text-left transition-all duration-200 last:border-b-0 hover:bg-dark-800/50 ${
|
||||
!notification.is_read ? 'bg-accent-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-800/50">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm leading-relaxed ${!notification.is_read ? 'font-medium text-dark-100' : 'text-dark-300'}`}
|
||||
>
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{formatTime(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span>
|
||||
<span className="block h-2.5 w-2.5 rounded-full bg-accent-500 shadow-lg shadow-accent-500/50"></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -271,23 +316,25 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-2xl bg-dark-800/50 flex items-center justify-center mx-auto mb-3 text-dark-500">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-dark-800/50 text-dark-500">
|
||||
<BellIcon />
|
||||
</div>
|
||||
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p>
|
||||
<p className="text-sm text-dark-500">
|
||||
{t('notifications.noNotifications', 'No notifications')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notificationsData?.items && notificationsData.items.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-dark-700/50 bg-dark-800/30">
|
||||
<div className="border-t border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
navigate(isAdmin ? '/admin/tickets' : '/support')
|
||||
setIsOpen(false);
|
||||
navigate(isAdmin ? '/admin/tickets' : '/support');
|
||||
}}
|
||||
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1 transition-colors"
|
||||
className="w-full py-1 text-center text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{t('notifications.viewAll', 'View all tickets')}
|
||||
</button>
|
||||
@@ -296,5 +343,5 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,99 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface ToastOptions {
|
||||
type?: 'success' | 'error' | 'info' | 'warning'
|
||||
message: string
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
duration?: number
|
||||
onClick?: () => void
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
message: string;
|
||||
title?: string;
|
||||
icon?: ReactNode;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface Toast extends ToastOptions {
|
||||
id: number
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (options: ToastOptions) => void
|
||||
showToast: (options: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null)
|
||||
const ToastContext = createContext<ToastContextType | null>(null);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const showToast = useCallback((options: ToastOptions) => {
|
||||
const id = Date.now() + Math.random() // Avoid ID collision
|
||||
const toast: Toast = { id, duration: 5000, type: 'info', ...options }
|
||||
const id = Date.now() + Math.random(); // Avoid ID collision
|
||||
const toast: Toast = { id, duration: 5000, type: 'info', ...options };
|
||||
|
||||
setToasts(prev => [...prev, toast])
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
timersRef.current.delete(id)
|
||||
}, toast.duration)
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timersRef.current.delete(id);
|
||||
}, toast.duration);
|
||||
|
||||
timersRef.current.set(id, timer)
|
||||
}, [])
|
||||
timersRef.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
// Clear timer when manually removing
|
||||
const timer = timersRef.current.get(id)
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timersRef.current.delete(id)
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
// Cleanup all timers on unmount
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
const handleClick = () => {
|
||||
if (toast.onClick) {
|
||||
toast.onClick()
|
||||
onClose()
|
||||
toast.onClick();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const typeStyles = {
|
||||
success: {
|
||||
@@ -115,81 +120,105 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
icon: 'text-accent-400',
|
||||
iconBg: 'bg-accent-500/20',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const style = typeStyles[toast.type || 'info']
|
||||
const style = typeStyles[toast.type || 'info'];
|
||||
|
||||
const defaultIcons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
pointer-events-auto
|
||||
w-80 sm:w-96
|
||||
${style.bg}
|
||||
backdrop-blur-xl
|
||||
border ${style.border}
|
||||
rounded-2xl
|
||||
shadow-2xl shadow-black/20
|
||||
overflow-hidden
|
||||
animate-slide-in-right
|
||||
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
|
||||
transition-transform duration-200
|
||||
`}
|
||||
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border backdrop-blur-xl ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/20 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} />
|
||||
<div className={`absolute inset-0 ${style.bg} opacity-50 blur-xl`} />
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}>
|
||||
<div
|
||||
className={`h-10 w-10 flex-shrink-0 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}
|
||||
>
|
||||
{toast.icon || defaultIcons[toast.type || 'info']}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{toast.title && (
|
||||
<p className="text-sm font-semibold text-dark-100 mb-0.5">
|
||||
{toast.title}
|
||||
</p>
|
||||
<p className="mb-0.5 text-sm font-semibold text-dark-100">{toast.title}</p>
|
||||
)}
|
||||
<p className="text-sm text-dark-300 leading-relaxed">
|
||||
{toast.message}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="flex-shrink-0 w-6 h-6 rounded-lg hover:bg-dark-700/50 flex items-center justify-center text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -206,5 +235,5 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,285 +1,345 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { balanceApi } from '../api/balance'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
||||
import type { PaymentMethod } from '../types'
|
||||
import BentoCard from './ui/BentoCard'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { balanceApi } from '../api/balance';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||
import type { PaymentMethod } from '../types';
|
||||
import BentoCard from './ui/BentoCard';
|
||||
|
||||
// Icons
|
||||
const CloseIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const StarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CryptoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ExternalLinkIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
interface TopUpModalProps {
|
||||
method: PaymentMethod
|
||||
onClose: () => void
|
||||
initialAmountRubles?: number
|
||||
method: PaymentMethod;
|
||||
onClose: () => void;
|
||||
initialAmountRubles?: number;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.innerWidth < 640
|
||||
})
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 640;
|
||||
});
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 640)
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
return isMobile
|
||||
const check = () => setIsMobile(window.innerWidth < 640);
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
// Get method icon based on method type
|
||||
const getMethodIcon = (methodId: string) => {
|
||||
const id = methodId.toLowerCase()
|
||||
if (id.includes('stars')) return <StarIcon />
|
||||
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />
|
||||
return <CardIcon />
|
||||
}
|
||||
const id = methodId.toLowerCase();
|
||||
if (id.includes('stars')) return <StarIcon />;
|
||||
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />;
|
||||
return <CardIcon />;
|
||||
};
|
||||
|
||||
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isMobileScreen = useIsMobile()
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||
useCurrency();
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isMobileScreen = useIsMobile();
|
||||
|
||||
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
|
||||
const safeBottom = isTelegramWebApp
|
||||
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
||||
: 0;
|
||||
|
||||
const getInitialAmount = (): string => {
|
||||
if (!initialAmountRubles || initialAmountRubles <= 0) return ''
|
||||
const converted = convertAmount(initialAmountRubles)
|
||||
return (targetCurrency === 'IRR' || targetCurrency === 'RUB')
|
||||
if (!initialAmountRubles || initialAmountRubles <= 0) return '';
|
||||
const converted = convertAmount(initialAmountRubles);
|
||||
return targetCurrency === 'IRR' || targetCurrency === 'RUB'
|
||||
? Math.ceil(converted).toString()
|
||||
: converted.toFixed(2)
|
||||
}
|
||||
: converted.toFixed(2);
|
||||
};
|
||||
|
||||
const [amount, setAmount] = useState(getInitialAmount)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [amount, setAmount] = useState(getInitialAmount);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
||||
)
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isInputFocused, setIsInputFocused] = useState(false)
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null,
|
||||
);
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Keyboard: Escape to close (PC)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleClose()
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleClose])
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
// Telegram back button (Android)
|
||||
useEffect(() => {
|
||||
if (!webApp?.BackButton) return
|
||||
webApp.BackButton.show()
|
||||
webApp.BackButton.onClick(handleClose)
|
||||
if (!webApp?.BackButton) return;
|
||||
webApp.BackButton.show();
|
||||
webApp.BackButton.onClick(handleClose);
|
||||
return () => {
|
||||
webApp.BackButton.offClick(handleClose)
|
||||
webApp.BackButton.hide()
|
||||
}
|
||||
}, [webApp, handleClose])
|
||||
webApp.BackButton.offClick(handleClose);
|
||||
webApp.BackButton.hide();
|
||||
};
|
||||
}, [webApp, handleClose]);
|
||||
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
const scrollY = window.scrollY
|
||||
const scrollY = window.scrollY;
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[data-modal-content]')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-modal-content]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
const preventWheel = (e: WheelEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[data-modal-content]')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
||||
document.body.style.overflow = 'hidden'
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-modal-content]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
document.addEventListener('wheel', preventWheel, { passive: false });
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('touchmove', preventScroll)
|
||||
document.removeEventListener('wheel', preventWheel)
|
||||
document.body.style.overflow = ''
|
||||
window.scrollTo(0, scrollY)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
document.removeEventListener('wheel', preventWheel);
|
||||
document.body.style.overflow = '';
|
||||
window.scrollTo(0, scrollY);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasOptions = method.options && method.options.length > 0
|
||||
const minRubles = method.min_amount_kopeks / 100
|
||||
const maxRubles = method.max_amount_kopeks / 100
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
||||
const isStarsMethod = methodKey.includes('stars')
|
||||
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
|
||||
const hasOptions = method.options && method.options.length > 0;
|
||||
const minRubles = method.min_amount_kopeks / 100;
|
||||
const maxRubles = method.max_amount_kopeks / 100;
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const isStarsMethod = methodKey.includes('stars');
|
||||
const methodName =
|
||||
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
|
||||
|
||||
const starsPaymentMutation = useMutation({
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||
onSuccess: (data) => {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return }
|
||||
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (!data.invoice_url) {
|
||||
setError('Сервер не вернул ссылку на оплату');
|
||||
return;
|
||||
}
|
||||
if (!webApp?.openInvoice) {
|
||||
setError('Оплата Stars доступна только в Telegram Mini App');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
webApp.openInvoice(data.invoice_url, (status) => {
|
||||
if (status === 'paid') { setError(null); onClose() }
|
||||
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
|
||||
})
|
||||
} catch (e) { setError('Ошибка: ' + String(e)) }
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string }, status?: number } }
|
||||
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`)
|
||||
},
|
||||
})
|
||||
|
||||
const topUpMutation = useMutation<{
|
||||
payment_id: string; payment_url?: string; invoice_url?: string
|
||||
amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null
|
||||
}, unknown, number>({
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||
onSuccess: (data) => {
|
||||
const redirectUrl = data.payment_url || (data as any).invoice_url
|
||||
if (redirectUrl) {
|
||||
// Always show the payment link for user to click manually
|
||||
// This ensures it works on all platforms including iOS Safari
|
||||
setPaymentUrl(redirectUrl)
|
||||
if (status === 'paid') {
|
||||
setError(null);
|
||||
onClose();
|
||||
} else if (status === 'failed') {
|
||||
setError(t('wheel.starsPaymentFailed'));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setError('Ошибка: ' + String(e));
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
|
||||
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error')))
|
||||
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
|
||||
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const topUpMutation = useMutation<
|
||||
{
|
||||
payment_id: string;
|
||||
payment_url?: string;
|
||||
invoice_url?: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
},
|
||||
unknown,
|
||||
number
|
||||
>({
|
||||
mutationFn: (amountKopeks: number) =>
|
||||
balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||
onSuccess: (data) => {
|
||||
const redirectUrl = data.payment_url || data.invoice_url;
|
||||
if (redirectUrl) {
|
||||
// Always show the payment link for user to click manually
|
||||
// This ensures it works on all platforms including iOS Safari
|
||||
setPaymentUrl(redirectUrl);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '';
|
||||
setError(
|
||||
detail.includes('not yet implemented') ? t('balance.useBot') : detail || t('common.error'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null)
|
||||
setPaymentUrl(null)
|
||||
inputRef.current?.blur()
|
||||
setError(null);
|
||||
setPaymentUrl(null);
|
||||
inputRef.current?.blur();
|
||||
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
||||
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.')
|
||||
return
|
||||
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.');
|
||||
return;
|
||||
}
|
||||
if (hasOptions && !selectedOption) { setError('Выберите способ'); return }
|
||||
const amountCurrency = parseFloat(amount)
|
||||
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return }
|
||||
const amountRubles = convertToRub(amountCurrency)
|
||||
if (hasOptions && !selectedOption) {
|
||||
setError('Выберите способ');
|
||||
return;
|
||||
}
|
||||
const amountCurrency = parseFloat(amount);
|
||||
if (isNaN(amountCurrency) || amountCurrency <= 0) {
|
||||
setError('Введите сумму');
|
||||
return;
|
||||
}
|
||||
const amountRubles = convertToRub(amountCurrency);
|
||||
if (amountRubles < minRubles || amountRubles > maxRubles) {
|
||||
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); return
|
||||
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`);
|
||||
return;
|
||||
}
|
||||
|
||||
const amountKopeks = Math.round(amountRubles * 100)
|
||||
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) }
|
||||
else { topUpMutation.mutate(amountKopeks) }
|
||||
}
|
||||
const amountKopeks = Math.round(amountRubles * 100);
|
||||
if (isStarsMethod) {
|
||||
starsPaymentMutation.mutate(amountKopeks);
|
||||
} else {
|
||||
topUpMutation.mutate(amountKopeks);
|
||||
}
|
||||
};
|
||||
|
||||
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles)
|
||||
const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2
|
||||
const getQuickValue = (rub: number) => (targetCurrency === 'IRR')
|
||||
? Math.round(convertAmount(rub)).toString()
|
||||
: convertAmount(rub).toFixed(currencyDecimals)
|
||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
|
||||
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles);
|
||||
const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2;
|
||||
const getQuickValue = (rub: number) =>
|
||||
targetCurrency === 'IRR'
|
||||
? Math.round(convertAmount(rub)).toString()
|
||||
: convertAmount(rub).toFixed(currencyDecimals);
|
||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
if (!paymentUrl) return
|
||||
if (!paymentUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(paymentUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
await navigator.clipboard.writeText(paymentUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.warn('Failed to copy:', e)
|
||||
console.warn('Failed to copy:', e);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Auto-focus input - works on mobile in Telegram WebApp
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.focus();
|
||||
if (isMobileScreen) {
|
||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Content JSX - shared between mobile and desktop
|
||||
const contentJSX = (
|
||||
<div className="space-y-5">
|
||||
{/* Header icon and method */}
|
||||
<div className="flex items-center gap-4 pb-1">
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${
|
||||
isStarsMethod
|
||||
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
|
||||
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
|
||||
}`}>
|
||||
<div className="w-7 h-7 flex items-center justify-center">
|
||||
{getMethodIcon(method.id)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
|
||||
isStarsMethod
|
||||
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
|
||||
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center">{getMethodIcon(method.id)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-dark-100">{methodName}</h3>
|
||||
@@ -299,16 +359,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedOption(opt.id)}
|
||||
className={`relative py-3 px-4 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||
className={`relative rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 ${
|
||||
selectedOption === opt.id
|
||||
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40'
|
||||
: 'bg-dark-800/70 text-dark-300 hover:bg-dark-700/70 border border-dark-700/50'
|
||||
: 'border border-dark-700/50 bg-dark-800/70 text-dark-300 hover:bg-dark-700/70'
|
||||
}`}
|
||||
>
|
||||
{opt.name}
|
||||
{selectedOption === opt.id && (
|
||||
<span className="absolute top-1.5 right-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-accent-500 block" />
|
||||
<span className="absolute right-1.5 top-1.5">
|
||||
<span className="block h-2 w-2 rounded-full bg-accent-500" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -321,11 +381,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
||||
isInputFocused
|
||||
? 'ring-2 ring-accent-500/50 bg-dark-800'
|
||||
: 'bg-dark-800/70 border border-dark-700/50'
|
||||
}`}>
|
||||
<div
|
||||
className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
||||
isInputFocused
|
||||
? 'bg-dark-800 ring-2 ring-accent-500/50'
|
||||
: 'border border-dark-700/50 bg-dark-800/70'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
@@ -335,9 +397,14 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full h-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
||||
className="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -349,16 +416,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !amount || parseFloat(amount) <= 0}
|
||||
className={`shrink-0 h-14 px-6 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden flex items-center justify-center gap-2 ${
|
||||
className={`flex h-14 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 text-base font-bold transition-colors duration-200 ${
|
||||
isPending || !amount || parseFloat(amount) <= 0
|
||||
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
|
||||
? 'cursor-not-allowed bg-dark-700 text-dark-500'
|
||||
: isStarsMethod
|
||||
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
|
||||
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
|
||||
}`}
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon />
|
||||
@@ -373,39 +440,54 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
{quickAmounts.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{quickAmounts.map((a) => {
|
||||
const val = getQuickValue(a)
|
||||
const isSelected = amount === val
|
||||
const val = getQuickValue(a);
|
||||
const isSelected = amount === val;
|
||||
return (
|
||||
<BentoCard
|
||||
key={a}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
||||
onClick={() => {
|
||||
setAmount(val);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
hover
|
||||
glow={isSelected}
|
||||
className={`flex flex-col items-center justify-center py-3 px-2 ${
|
||||
isSelected
|
||||
? 'border-accent-500/50 bg-accent-500/10'
|
||||
: ''
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 ${
|
||||
isSelected ? 'border-accent-500/50 bg-accent-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}>
|
||||
<span
|
||||
className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}
|
||||
>
|
||||
{formatAmount(a, 0)}
|
||||
</span>
|
||||
<span className={`text-xs mt-0.5 ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`mt-0.5 text-xs ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}
|
||||
>
|
||||
{currencySymbol}
|
||||
</span>
|
||||
</BentoCard>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-error-500/10 border border-error-500/20">
|
||||
<svg className="w-5 h-5 text-error-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-error-400">{error}</span>
|
||||
</div>
|
||||
@@ -413,14 +495,19 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
|
||||
{/* Payment link display - shown when URL is received */}
|
||||
{paymentUrl && (
|
||||
<div className="space-y-3 p-4 rounded-2xl bg-success-500/10 border border-success-500/20">
|
||||
<div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4">
|
||||
<div className="flex items-center gap-2 text-success-400">
|
||||
<CheckIcon />
|
||||
<span className="font-semibold">{t('balance.paymentReady', 'Ссылка на оплату готова')}</span>
|
||||
<span className="font-semibold">
|
||||
{t('balance.paymentReady', 'Ссылка на оплату готова')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')}
|
||||
{t(
|
||||
'balance.clickToOpenPayment',
|
||||
'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
|
||||
@@ -428,7 +515,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
href={paymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors"
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
|
||||
@@ -436,13 +523,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
|
||||
{/* Copy and link display */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 px-3 py-2 rounded-lg bg-dark-800/70 border border-dark-700/50">
|
||||
<p className="text-xs text-dark-500 truncate">{paymentUrl}</p>
|
||||
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
||||
<p className="truncate text-xs text-dark-500">{paymentUrl}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className={`shrink-0 p-2.5 rounded-lg transition-colors ${
|
||||
className={`shrink-0 rounded-lg p-2.5 transition-colors ${
|
||||
copied
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||
@@ -455,49 +542,48 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
// Render modal based on screen size - NO nested components!
|
||||
const modalContent = isMobileScreen ? (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[9998] bg-black/70"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
data-modal-content
|
||||
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-3xl max-h-[90vh] flex flex-col overflow-hidden"
|
||||
style={{ paddingBottom: safeBottom ? `${safeBottom + 20}px` : 'max(20px, env(safe-area-inset-bottom))' }}
|
||||
className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
|
||||
style={{
|
||||
paddingBottom: safeBottom
|
||||
? `${safeBottom + 20}px`
|
||||
: 'max(20px, env(safe-area-inset-bottom))',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-dark-600" />
|
||||
<div className="flex justify-center pb-1 pt-3">
|
||||
<div className="h-1 w-10 rounded-full bg-dark-600" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon />
|
||||
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
|
||||
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400 transition-colors"
|
||||
className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent mx-5" />
|
||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 overflow-y-auto">
|
||||
{contentJSX}
|
||||
</div>
|
||||
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -507,35 +593,33 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
>
|
||||
<div
|
||||
data-modal-content
|
||||
className="w-full max-w-md bg-dark-900 rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden"
|
||||
className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-dark-800/80 to-dark-800/40 border-b border-dark-700/50">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center text-accent-400">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
|
||||
<WalletIcon />
|
||||
</div>
|
||||
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
|
||||
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 -mr-1 rounded-xl hover:bg-dark-700 text-dark-400 transition-colors"
|
||||
className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{contentJSX}
|
||||
</div>
|
||||
<div className="p-6">{contentJSX}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
return createPortal(modalContent, document.body)
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
return modalContent
|
||||
return modalContent;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../../api/branding'
|
||||
import { CheckIcon, CloseIcon } from './icons'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../../api/branding';
|
||||
import { CheckIcon, CloseIcon } from './icons';
|
||||
|
||||
export function AnalyticsTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Editing states
|
||||
const [editingYandex, setEditingYandex] = useState(false)
|
||||
const [editingGoogleId, setEditingGoogleId] = useState(false)
|
||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false)
|
||||
const [yandexValue, setYandexValue] = useState('')
|
||||
const [googleIdValue, setGoogleIdValue] = useState('')
|
||||
const [googleLabelValue, setGoogleLabelValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingYandex, setEditingYandex] = useState(false);
|
||||
const [editingGoogleId, setEditingGoogleId] = useState(false);
|
||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false);
|
||||
const [yandexValue, setYandexValue] = useState('');
|
||||
const [googleIdValue, setGoogleIdValue] = useState('');
|
||||
const [googleLabelValue, setGoogleLabelValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Query
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ['analytics-counters'],
|
||||
queryFn: brandingApi.getAnalyticsCounters,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: brandingApi.updateAnalyticsCounters,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] })
|
||||
setError(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
setError(detail || t('common.error'))
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
setError(detail || t('common.error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSaveYandex = () => {
|
||||
updateMutation.mutate(
|
||||
{ yandex_metrika_id: yandexValue.trim() },
|
||||
{ onSuccess: () => setEditingYandex(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGoogleId = () => {
|
||||
updateMutation.mutate(
|
||||
{ google_ads_id: googleIdValue.trim() },
|
||||
{ onSuccess: () => setEditingGoogleId(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGoogleLabel = () => {
|
||||
updateMutation.mutate(
|
||||
{ google_ads_label: googleLabelValue.trim() },
|
||||
{ onSuccess: () => setEditingGoogleLabel(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const yandexActive = Boolean(analytics?.yandex_metrika_id)
|
||||
const googleActive = Boolean(analytics?.google_ads_id)
|
||||
const yandexActive = Boolean(analytics?.yandex_metrika_id);
|
||||
const googleActive = Boolean(analytics?.google_ads_id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-2xl bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
|
||||
<div className="rounded-2xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yandex Metrika */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.yandexMetrika')}
|
||||
</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
yandexActive
|
||||
? 'bg-success-500/15 text-success-400'
|
||||
: 'bg-dark-700/50 text-dark-500'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
yandexActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||
/>
|
||||
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
||||
<p className="mb-5 ml-[52px] text-sm text-dark-400">
|
||||
{t('admin.settings.yandexMetrikaDesc')}
|
||||
</p>
|
||||
|
||||
@@ -106,73 +108,84 @@ export function AnalyticsTab() {
|
||||
value={yandexValue}
|
||||
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder={t('admin.settings.yandexIdPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveYandex}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingYandex(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingYandex(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.yandex_metrika_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.yandex_metrika_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setYandexValue(analytics?.yandex_metrika_id || '')
|
||||
setEditingYandex(true)
|
||||
setError(null)
|
||||
setYandexValue(analytics?.yandex_metrika_id || '');
|
||||
setEditingYandex(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.yandexIdHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Ads */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.googleAds')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.googleAds')}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
googleActive
|
||||
? 'bg-success-500/15 text-success-400'
|
||||
: 'bg-dark-700/50 text-dark-500'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
googleActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||
/>
|
||||
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
||||
{t('admin.settings.googleAdsDesc')}
|
||||
</p>
|
||||
<p className="mb-5 ml-[52px] text-sm text-dark-400">{t('admin.settings.googleAdsDesc')}</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Conversion ID */}
|
||||
@@ -187,45 +200,58 @@ export function AnalyticsTab() {
|
||||
value={googleIdValue}
|
||||
onChange={(e) => setGoogleIdValue(e.target.value)}
|
||||
placeholder={t('admin.settings.googleIdPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveGoogleId}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingGoogleId(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingGoogleId(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.google_ads_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.google_ads_id || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGoogleIdValue(analytics?.google_ads_id || '')
|
||||
setEditingGoogleId(true)
|
||||
setError(null)
|
||||
setGoogleIdValue(analytics?.google_ads_id || '');
|
||||
setEditingGoogleId(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.googleIdHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.googleIdHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Conversion Label */}
|
||||
@@ -240,55 +266,66 @@ export function AnalyticsTab() {
|
||||
value={googleLabelValue}
|
||||
onChange={(e) => setGoogleLabelValue(e.target.value)}
|
||||
placeholder={t('admin.settings.googleLabelPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveGoogleLabel}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingGoogleLabel(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingGoogleLabel(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.google_ads_label ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.google_ads_label || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGoogleLabelValue(analytics?.google_ads_label || '')
|
||||
setEditingGoogleLabel(true)
|
||||
setError(null)
|
||||
setGoogleLabelValue(analytics?.google_ads_label || '');
|
||||
setEditingGoogleLabel(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.googleLabelHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.googleLabelHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info block */}
|
||||
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
|
||||
<p className="text-sm text-dark-500 leading-relaxed">
|
||||
{t('admin.settings.analyticsHint')}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-4">
|
||||
<p className="text-sm leading-relaxed text-dark-500">{t('admin.settings.analyticsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,130 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding'
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground'
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'
|
||||
import { Toggle } from './Toggle'
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding';
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground';
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
interface BrandingTabProps {
|
||||
accentColor?: string
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
queryFn: brandingApi.getAnimationEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: fullscreenSettings } = useQuery({
|
||||
queryKey: ['fullscreen-enabled'],
|
||||
queryFn: brandingApi.getFullscreenEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: emailAuthSettings } = useQuery({
|
||||
queryKey: ['email-auth-enabled'],
|
||||
queryFn: brandingApi.getEmailAuthEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateBrandingMutation = useMutation({
|
||||
mutationFn: brandingApi.updateName,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setEditingName(false)
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
setEditingName(false);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: brandingApi.uploadLogo,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const deleteLogoMutation = useMutation({
|
||||
mutationFn: brandingApi.deleteLogo,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateAnimationMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
setCachedAnimationEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
|
||||
setCachedAnimationEnabled(data.enabled);
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateFullscreenMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
setCachedFullscreenEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
|
||||
setCachedFullscreenEnabled(data.enabled);
|
||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateEmailAuthMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadLogoMutation.mutate(file)
|
||||
uploadLogoMutation.mutate(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Logo & Name */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.logoAndName')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.logoAndName')}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center text-3xl font-bold text-white overflow-hidden"
|
||||
className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl text-3xl font-bold text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`,
|
||||
}}
|
||||
>
|
||||
{branding?.has_custom_logo ? (
|
||||
<img src={brandingApi.getLogoUrl(branding) ?? undefined} alt="Logo" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={brandingApi.getLogoUrl(branding) ?? undefined}
|
||||
alt="Logo"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
branding?.logo_letter || 'V'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -129,7 +135,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-xl bg-dark-700 hover:bg-dark-600 text-dark-200 text-sm transition-colors disabled:opacity-50"
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-xl bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<UploadIcon />
|
||||
</button>
|
||||
@@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
className="px-3 py-2 rounded-xl bg-dark-700 hover:bg-error-500/20 text-dark-400 hover:text-error-400 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-dark-700 px-3 py-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
|
||||
{/* Name */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">{t('admin.settings.projectName')}</label>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.projectName')}
|
||||
</label>
|
||||
{editingName ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
maxLength={50}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateBrandingMutation.mutate(newName)}
|
||||
disabled={updateBrandingMutation.isPending}
|
||||
className="px-4 py-2 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingName(false)}
|
||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg text-dark-100">{branding?.name || t('admin.settings.notSpecified')}</span>
|
||||
<span className="text-lg text-dark-100">
|
||||
{branding?.name || t('admin.settings.notSpecified')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewName(branding?.name ?? '')
|
||||
setEditingName(true)
|
||||
setNewName(branding?.name ?? '');
|
||||
setEditingName(true);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
@@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Animation & Fullscreen toggles */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.interfaceOptions')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.interfaceOptions')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.animatedBackground')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('admin.settings.animatedBackground')}
|
||||
</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
@@ -206,19 +220,23 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.autoFullscreen')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('admin.settings.autoFullscreen')}
|
||||
</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={fullscreenSettings?.enabled ?? false}
|
||||
onChange={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
|
||||
onChange={() =>
|
||||
updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))
|
||||
}
|
||||
disabled={updateFullscreenMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
|
||||
@@ -232,5 +250,5 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
||||
import { StarIcon } from './icons'
|
||||
import { SettingRow } from './SettingRow'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||
import { StarIcon } from './icons';
|
||||
import { SettingRow } from './SettingRow';
|
||||
|
||||
interface FavoritesTabProps {
|
||||
settings: SettingDefinition[]
|
||||
isFavorite: (key: string) => boolean
|
||||
toggleFavorite: (key: string) => void
|
||||
settings: SettingDefinition[];
|
||||
isFavorite: (key: string) => boolean;
|
||||
toggleFavorite: (key: string) => void;
|
||||
}
|
||||
|
||||
export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminSettingsApi.updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettingMutation = useMutation({
|
||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (settings.length === 0) {
|
||||
return (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="flex justify-center mb-4 text-dark-500">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<div className="mb-4 flex justify-center text-dark-500">
|
||||
<StarIcon filled={false} />
|
||||
</div>
|
||||
<p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p>
|
||||
<p className="text-dark-500 text-sm mt-1">{t('admin.settings.favoritesHint')}</p>
|
||||
<p className="mt-1 text-sm text-dark-500">{t('admin.settings.favoritesHint')}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{settings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
@@ -55,5 +56,5 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { SettingDefinition } from '../../api/adminSettings'
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons';
|
||||
|
||||
interface SettingInputProps {
|
||||
setting: SettingDefinition
|
||||
onUpdate: (value: string) => void
|
||||
disabled?: boolean
|
||||
setting: SettingDefinition;
|
||||
onUpdate: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Check if value is likely JSON or multi-line
|
||||
function isLongValue(value: string | null | undefined): boolean {
|
||||
if (!value) return false
|
||||
const str = String(value)
|
||||
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{')
|
||||
if (!value) return false;
|
||||
const str = String(value);
|
||||
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{');
|
||||
}
|
||||
|
||||
// Check if key suggests it's a list or JSON config
|
||||
function isListOrJsonKey(key: string): boolean {
|
||||
const lowerKey = key.toLowerCase()
|
||||
const lowerKey = key.toLowerCase();
|
||||
return (
|
||||
lowerKey.includes('_items') ||
|
||||
lowerKey.includes('_config') ||
|
||||
@@ -28,40 +28,40 @@ function isListOrJsonKey(key: string): boolean {
|
||||
lowerKey.includes('_periods') ||
|
||||
lowerKey.includes('_discounts') ||
|
||||
lowerKey.includes('_packages')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentValue = String(setting.current ?? '')
|
||||
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key)
|
||||
const currentValue = String(setting.current ?? '');
|
||||
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isEditing) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px'
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px';
|
||||
}
|
||||
}, [value, isEditing])
|
||||
}, [value, isEditing]);
|
||||
|
||||
const handleStart = () => {
|
||||
setValue(currentValue)
|
||||
setIsEditing(true)
|
||||
}
|
||||
setValue(currentValue);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
onUpdate(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false)
|
||||
setValue('')
|
||||
}
|
||||
setIsEditing(false);
|
||||
setValue('');
|
||||
};
|
||||
|
||||
// Dropdown for choices
|
||||
if (setting.choices && setting.choices.length > 0) {
|
||||
@@ -70,7 +70,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={currentValue}
|
||||
onChange={(e) => onUpdate(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50 min-w-[140px] cursor-pointer"
|
||||
className="min-w-[140px] cursor-pointer rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
{setting.choices.map((choice, idx) => (
|
||||
<option key={idx} value={String(choice.value)}>
|
||||
@@ -78,7 +78,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Editing mode - Textarea for long values
|
||||
@@ -90,26 +90,26 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleCancel()
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
// Ctrl+Enter to save
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave()
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="Введите значение..."
|
||||
className="w-full bg-dark-700 border border-accent-500 rounded-xl px-4 py-3 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 font-mono resize-none min-h-[100px]"
|
||||
className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors text-sm"
|
||||
className="rounded-lg bg-dark-600 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:bg-dark-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors text-sm flex items-center gap-1.5"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<CheckIcon />
|
||||
Сохранить
|
||||
@@ -117,7 +117,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Editing mode - Regular input
|
||||
@@ -130,50 +130,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave()
|
||||
if (e.key === 'Escape') handleCancel()
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="Введите значение..."
|
||||
className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 w-48 sm:w-56"
|
||||
className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors"
|
||||
className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
|
||||
title="Сохранить (Enter)"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors"
|
||||
className="rounded-lg bg-dark-600 p-2 text-dark-300 transition-colors hover:bg-dark-500"
|
||||
title="Отмена (Esc)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display mode - Long value preview
|
||||
if (needsTextarea) {
|
||||
const displayValue = currentValue || '-'
|
||||
const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue
|
||||
const displayValue = currentValue || '-';
|
||||
const previewValue =
|
||||
displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={disabled}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-700 transition-colors disabled:opacity-50 text-left font-mono group"
|
||||
className="group w-full rounded-xl border border-dark-600 bg-dark-700/50 px-4 py-3 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-700 disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="break-all line-clamp-2 flex-1">{previewValue}</span>
|
||||
<span className="text-dark-500 group-hover:text-accent-400 transition-colors flex-shrink-0">
|
||||
<span className="line-clamp-2 flex-1 break-all">{previewValue}</span>
|
||||
<span className="flex-shrink-0 text-dark-500 transition-colors group-hover:text-accent-400">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display mode - Short value
|
||||
@@ -181,12 +182,12 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={disabled}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-600 transition-colors disabled:opacity-50 min-w-[100px] text-left font-mono truncate max-w-[200px] flex items-center gap-2 group"
|
||||
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<span className="truncate flex-1">{currentValue || '-'}</span>
|
||||
<span className="text-dark-500 group-hover:text-accent-400 transition-colors opacity-0 group-hover:opacity-100">
|
||||
<span className="flex-1 truncate">{currentValue || '-'}</span>
|
||||
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingDefinition } from '../../api/adminSettings'
|
||||
import { StarIcon, LockIcon, RefreshIcon } from './icons'
|
||||
import { SettingInput } from './SettingInput'
|
||||
import { Toggle } from './Toggle'
|
||||
import { formatSettingKey, stripHtml } from './utils'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { StarIcon, LockIcon, RefreshIcon } from './icons';
|
||||
import { SettingInput } from './SettingInput';
|
||||
import { Toggle } from './Toggle';
|
||||
import { formatSettingKey, stripHtml } from './utils';
|
||||
|
||||
interface SettingRowProps {
|
||||
setting: SettingDefinition
|
||||
isFavorite: boolean
|
||||
onToggleFavorite: () => void
|
||||
onUpdate: (value: string) => void
|
||||
onReset: () => void
|
||||
isUpdating?: boolean
|
||||
isResetting?: boolean
|
||||
setting: SettingDefinition;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
onUpdate: (value: string) => void;
|
||||
onReset: () => void;
|
||||
isUpdating?: boolean;
|
||||
isResetting?: boolean;
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
@@ -22,18 +22,18 @@ export function SettingRow({
|
||||
onUpdate,
|
||||
onReset,
|
||||
isUpdating,
|
||||
isResetting
|
||||
isResetting,
|
||||
}: SettingRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formattedKey = formatSettingKey(setting.name || setting.key)
|
||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
|
||||
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null
|
||||
const formattedKey = formatSettingKey(setting.name || setting.key);
|
||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
|
||||
|
||||
// Check if this is a long/complex value
|
||||
const isLongValue = (() => {
|
||||
const val = String(setting.current ?? '')
|
||||
const key = setting.key.toLowerCase()
|
||||
const val = String(setting.current ?? '');
|
||||
const key = setting.key.toLowerCase();
|
||||
return (
|
||||
val.length > 50 ||
|
||||
val.includes('\n') ||
|
||||
@@ -44,40 +44,40 @@ export function SettingRow({
|
||||
key.includes('_keywords') ||
|
||||
key.includes('_template') ||
|
||||
key.includes('_packages')
|
||||
)
|
||||
})()
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="group p-4 sm:p-5 rounded-2xl bg-dark-800/40 border border-dark-700/40 hover:border-dark-600/60 hover:bg-dark-800/60 transition-all">
|
||||
<div className="group rounded-2xl border border-dark-700/40 bg-dark-800/40 p-4 transition-all hover:border-dark-600/60 hover:bg-dark-800/60 sm:p-5">
|
||||
{/* Header row - name, badges, favorite */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-dark-100 text-base">{displayName}</h3>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{displayName}</h3>
|
||||
{setting.has_override && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400 font-medium">
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
|
||||
{t('admin.settings.modified')}
|
||||
</span>
|
||||
)}
|
||||
{setting.read_only && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600/50 text-dark-400 font-medium flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400">
|
||||
<LockIcon />
|
||||
{t('admin.settings.readOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-dark-400 mt-1.5 leading-relaxed">{description}</p>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={onToggleFavorite}
|
||||
className={`p-2 rounded-xl transition-all flex-shrink-0 ${
|
||||
className={`flex-shrink-0 rounded-xl p-2 transition-all ${
|
||||
isFavorite
|
||||
? 'text-warning-400 bg-warning-500/15 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 hover:text-warning-400 hover:bg-dark-700/50 opacity-0 group-hover:opacity-100'
|
||||
? 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
@@ -87,17 +87,19 @@ export function SettingRow({
|
||||
|
||||
{/* Setting key (muted) */}
|
||||
<div className="mb-3">
|
||||
<code className="text-xs text-dark-500 font-mono bg-dark-900/50 px-2 py-1 rounded">
|
||||
<code className="rounded bg-dark-900/50 px-2 py-1 font-mono text-xs text-dark-500">
|
||||
{setting.key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Control section */}
|
||||
<div className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} pt-3 border-t border-dark-700/30`}>
|
||||
<div
|
||||
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
|
||||
>
|
||||
{setting.read_only ? (
|
||||
// Read-only display
|
||||
<div className="flex items-center gap-2 text-dark-300 bg-dark-700/30 rounded-lg px-4 py-2.5">
|
||||
<span className="font-mono text-sm break-all">{String(setting.current ?? '-')}</span>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
|
||||
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||
</div>
|
||||
) : setting.type === 'bool' ? (
|
||||
// Boolean toggle
|
||||
@@ -108,7 +110,11 @@ export function SettingRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={setting.current === true || setting.current === 'true'}
|
||||
onChange={() => onUpdate(setting.current === true || setting.current === 'true' ? 'false' : 'true')}
|
||||
onChange={() =>
|
||||
onUpdate(
|
||||
setting.current === true || setting.current === 'true' ? 'false' : 'true',
|
||||
)
|
||||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
{/* Reset button for boolean */}
|
||||
@@ -116,7 +122,7 @@ export function SettingRow({
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50"
|
||||
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -126,18 +132,16 @@ export function SettingRow({
|
||||
</div>
|
||||
) : (
|
||||
// Input field
|
||||
<div className={`${isLongValue ? 'w-full' : 'flex items-center gap-2 flex-1 justify-end'}`}>
|
||||
<SettingInput
|
||||
setting={setting}
|
||||
onUpdate={onUpdate}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<div
|
||||
className={`${isLongValue ? 'w-full' : 'flex flex-1 items-center justify-end gap-2'}`}
|
||||
>
|
||||
<SettingInput setting={setting} onUpdate={onUpdate} disabled={isUpdating} />
|
||||
{/* Reset button for non-long values */}
|
||||
{!isLongValue && setting.has_override && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
className="flex-shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -153,7 +157,7 @@ export function SettingRow({
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="px-3 py-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 text-sm flex items-center gap-1.5"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -162,5 +166,5 @@ export function SettingRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchIcon, CloseIcon } from './icons'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SearchIcon, CloseIcon } from './icons';
|
||||
|
||||
interface SettingsSearchProps {
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
resultsCount?: number
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
resultsCount?: number;
|
||||
}
|
||||
|
||||
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('admin.settings.searchPlaceholder')}
|
||||
className="w-48 lg:w-64 pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
||||
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||
const { t } = useTranslation()
|
||||
export function SettingsSearchMobile({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 sm:hidden">
|
||||
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('admin.settings.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) {
|
||||
if (!searchQuery.trim()) return null
|
||||
export function SettingsSearchResults({
|
||||
searchQuery,
|
||||
resultsCount,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
resultsCount: number;
|
||||
}) {
|
||||
if (!searchQuery.trim()) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||
<span className="text-dark-400">
|
||||
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
|
||||
</span>
|
||||
{resultsCount > 0 && (
|
||||
<span className="text-dark-500">по запросу «{searchQuery}»</span>
|
||||
)}
|
||||
{resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection: string
|
||||
setActiveSection: (section: string) => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (open: boolean) => void
|
||||
favoritesCount: number
|
||||
activeSection: string;
|
||||
setActiveSection: (section: string) => void;
|
||||
mobileMenuOpen: boolean;
|
||||
setMobileMenuOpen: (open: boolean) => void;
|
||||
favoritesCount: number;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
@@ -15,27 +15,27 @@ export function SettingsSidebar({
|
||||
setActiveSection,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
favoritesCount
|
||||
favoritesCount,
|
||||
}: SettingsSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside className={`
|
||||
fixed lg:sticky lg:top-0 inset-y-0 left-0 z-50
|
||||
w-64 h-screen bg-dark-900 border-r border-dark-700/50 flex-shrink-0
|
||||
transform transition-transform duration-200 ease-in-out
|
||||
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/admin" className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="ml-auto p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
|
||||
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -43,39 +43,39 @@ export function SettingsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="p-2 space-y-1 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
|
||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.id}>
|
||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeSection === item.id
|
||||
const hasIcon = item.iconType === 'star'
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveSection(item.id)
|
||||
setMobileMenuOpen(false)
|
||||
setActiveSection(item.id);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800/50'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
||||
<span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400">
|
||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{favoritesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
||||
import { ChevronDownIcon } from './icons'
|
||||
import { SettingRow } from './SettingRow'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||
import { ChevronDownIcon } from './icons';
|
||||
import { SettingRow } from './SettingRow';
|
||||
|
||||
interface CategoryGroup {
|
||||
key: string
|
||||
label: string
|
||||
settings: SettingDefinition[]
|
||||
key: string;
|
||||
label: string;
|
||||
settings: SettingDefinition[];
|
||||
}
|
||||
|
||||
interface SettingsTabProps {
|
||||
categories: CategoryGroup[]
|
||||
searchQuery: string
|
||||
filteredSettings: SettingDefinition[]
|
||||
isFavorite: (key: string) => boolean
|
||||
toggleFavorite: (key: string) => void
|
||||
categories: CategoryGroup[];
|
||||
searchQuery: string;
|
||||
filteredSettings: SettingDefinition[];
|
||||
isFavorite: (key: string) => boolean;
|
||||
toggleFavorite: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsTab({
|
||||
@@ -24,49 +24,50 @@ export function SettingsTab({
|
||||
searchQuery,
|
||||
filteredSettings,
|
||||
isFavorite,
|
||||
toggleFavorite
|
||||
toggleFavorite,
|
||||
}: SettingsTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key)
|
||||
next.add(key);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminSettingsApi.updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettingMutation = useMutation({
|
||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// If searching, show flat list
|
||||
if (searchQuery) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{filteredSettings.length === 0 ? (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{filteredSettings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
@@ -82,46 +83,50 @@ export function SettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show accordion for subcategories
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{categories.map((cat) => {
|
||||
const isExpanded = expandedSections.has(cat.key)
|
||||
const isExpanded = expandedSections.has(cat.key);
|
||||
return (
|
||||
<div
|
||||
key={cat.key}
|
||||
className="rounded-2xl bg-dark-800/30 border border-dark-700/30 overflow-hidden"
|
||||
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
|
||||
>
|
||||
{/* Accordion header */}
|
||||
<button
|
||||
onClick={() => toggleSection(cat.key)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-dark-800/50 transition-colors"
|
||||
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-dark-100">{cat.label}</span>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-700 text-dark-400">
|
||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
|
||||
{cat.settings.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`transition-transform duration-200 text-dark-400 ${isExpanded ? 'rotate-180' : ''}`}>
|
||||
<div
|
||||
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Accordion content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 pt-0 border-t border-dark-700/30">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
|
||||
<div className="border-t border-dark-700/30 p-4 pt-0">
|
||||
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
|
||||
{cat.settings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
isFavorite={isFavorite(setting.key)}
|
||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
||||
onUpdate={(value) =>
|
||||
updateSettingMutation.mutate({ key: setting.key, value })
|
||||
}
|
||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||
isUpdating={updateSettingMutation.isPending}
|
||||
isResetting={resetSettingMutation.isPending}
|
||||
@@ -131,14 +136,14 @@ export function SettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { themeColorsApi } from '../../api/themeColors'
|
||||
import { DEFAULT_THEME_COLORS } from '../../types/theme'
|
||||
import { ColorPicker } from '../ColorPicker'
|
||||
import { applyThemeColors } from '../../hooks/useThemeColors'
|
||||
import { updateEnabledThemesCache } from '../../hooks/useTheme'
|
||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons'
|
||||
import { Toggle } from './Toggle'
|
||||
import { THEME_PRESETS } from './constants'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { themeColorsApi } from '../../api/themeColors';
|
||||
import { DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
import { ColorPicker } from '../ColorPicker';
|
||||
import { applyThemeColors } from '../../hooks/useThemeColors';
|
||||
import { updateEnabledThemesCache } from '../../hooks/useTheme';
|
||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
import { THEME_PRESETS } from './constants';
|
||||
|
||||
export function ThemeTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']))
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']));
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {
|
||||
next.delete(section)
|
||||
next.delete(section);
|
||||
} else {
|
||||
next.add(section)
|
||||
next.add(section);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: themeColors } = useQuery({
|
||||
queryKey: ['theme-colors'],
|
||||
queryFn: themeColorsApi.getColors,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: enabledThemes } = useQuery({
|
||||
queryKey: ['enabled-themes'],
|
||||
queryFn: themeColorsApi.getEnabledThemes,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateColorsMutation = useMutation({
|
||||
mutationFn: themeColorsApi.updateColors,
|
||||
onSuccess: (data) => {
|
||||
applyThemeColors(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
applyThemeColors(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetColorsMutation = useMutation({
|
||||
mutationFn: themeColorsApi.resetColors,
|
||||
onSuccess: (data) => {
|
||||
applyThemeColors(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
applyThemeColors(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateEnabledThemesMutation = useMutation({
|
||||
mutationFn: themeColorsApi.updateEnabledThemes,
|
||||
onSuccess: (data) => {
|
||||
updateEnabledThemesCache(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] })
|
||||
updateEnabledThemesCache(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Theme toggles */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.availableThemes')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.availableThemes')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<MoonIcon />
|
||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.darkTheme')}</span>
|
||||
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||
{t('admin.settings.darkTheme')}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabledThemes?.dark ?? true}
|
||||
onChange={() => {
|
||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return
|
||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) })
|
||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return;
|
||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) });
|
||||
}}
|
||||
disabled={updateEnabledThemesMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<SunIcon />
|
||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.lightTheme')}</span>
|
||||
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||
{t('admin.settings.lightTheme')}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabledThemes?.light ?? true}
|
||||
onChange={() => {
|
||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return
|
||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) })
|
||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return;
|
||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) });
|
||||
}}
|
||||
disabled={updateEnabledThemesMutation.isPending}
|
||||
/>
|
||||
@@ -104,30 +110,34 @@ export function ThemeTab() {
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<button
|
||||
onClick={() => toggleSection('presets')}
|
||||
className="w-full flex items-center justify-between"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.quickPresets')}</h3>
|
||||
<div className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.quickPresets')}
|
||||
</h3>
|
||||
<div
|
||||
className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('presets') && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{THEME_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => updateColorsMutation.mutate(preset.colors)}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="p-3 rounded-xl border border-dark-600 hover:border-dark-500 transition-all hover:scale-[1.02]"
|
||||
className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
|
||||
style={{ backgroundColor: preset.colors.darkBackground }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full ring-2 ring-white/20"
|
||||
className="h-4 w-4 rounded-full ring-2 ring-white/20"
|
||||
style={{ backgroundColor: preset.colors.accent }}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: preset.colors.darkText }}>
|
||||
@@ -135,9 +145,18 @@ export function ThemeTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.success }} />
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.warning }} />
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.error }} />
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.success }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.warning }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.error }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -146,13 +165,17 @@ export function ThemeTab() {
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<button
|
||||
onClick={() => toggleSection('colors')}
|
||||
className="w-full flex items-center justify-between"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.customColors')}</h3>
|
||||
<div className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.customColors')}
|
||||
</h3>
|
||||
<div
|
||||
className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
@@ -161,7 +184,9 @@ export function ThemeTab() {
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Accent */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.accentColor')}</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.accentColor')}
|
||||
</h4>
|
||||
<ColorPicker
|
||||
label={t('theme.accent')}
|
||||
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
||||
@@ -172,10 +197,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Dark theme */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||
<MoonIcon /> {t('admin.settings.darkTheme')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.background')}
|
||||
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
||||
@@ -205,10 +230,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Light theme */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||
<SunIcon /> {t('admin.settings.lightTheme')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.background')}
|
||||
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
||||
@@ -238,8 +263,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Status colors */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.statusColors')}</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.statusColors')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.success')}
|
||||
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
||||
@@ -265,7 +292,7 @@ export function ThemeTab() {
|
||||
<button
|
||||
onClick={() => resetColorsMutation.mutate()}
|
||||
disabled={resetColorsMutation.isPending}
|
||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.settings.resetAllColors')}
|
||||
</button>
|
||||
@@ -273,5 +300,5 @@ export function ThemeTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
disabled?: boolean
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||
@@ -9,13 +9,15 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||
<button
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent-500' : 'bg-dark-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-0'
|
||||
}`} />
|
||||
<div
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
|
||||
// Menu item types
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
iconType?: 'star' | null
|
||||
categories?: string[]
|
||||
id: string;
|
||||
iconType?: 'star' | null;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
id: string
|
||||
items: MenuItem[]
|
||||
id: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
// Sidebar menu configuration
|
||||
@@ -21,36 +21,215 @@ export const MENU_SECTIONS: MenuSection[] = [
|
||||
{ id: 'branding', iconType: null },
|
||||
{ id: 'theme', iconType: null },
|
||||
{ id: 'analytics', iconType: null },
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
items: [
|
||||
{ id: 'payments', iconType: null, categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'] },
|
||||
{ id: 'subscriptions', iconType: null, categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'] },
|
||||
{ id: 'interface', iconType: null, categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'] },
|
||||
{ id: 'notifications', iconType: null, categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'] },
|
||||
{
|
||||
id: 'payments',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'YOOKASSA',
|
||||
'CRYPTOBOT',
|
||||
'HELEKET',
|
||||
'PLATEGA',
|
||||
'TRIBUTE',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
'TELEGRAM',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'subscriptions',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'SUBSCRIPTIONS_CORE',
|
||||
'SIMPLE_SUBSCRIPTION',
|
||||
'PERIODS',
|
||||
'SUBSCRIPTION_PRICES',
|
||||
'TRAFFIC',
|
||||
'TRAFFIC_PACKAGES',
|
||||
'TRIAL',
|
||||
'AUTOPAY',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'interface',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'INTERFACE',
|
||||
'INTERFACE_BRANDING',
|
||||
'INTERFACE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
iconType: null,
|
||||
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
||||
},
|
||||
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
||||
{ id: 'system', iconType: null, categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'] },
|
||||
{ id: 'users', iconType: null, categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'] },
|
||||
]
|
||||
}
|
||||
]
|
||||
{
|
||||
id: 'system',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'CORE',
|
||||
'REMNAWAVE',
|
||||
'SERVER_STATUS',
|
||||
'MONITORING',
|
||||
'MAINTENANCE',
|
||||
'BACKUP',
|
||||
'VERSION',
|
||||
'WEB_API',
|
||||
'WEBHOOK',
|
||||
'LOG',
|
||||
'DEBUG',
|
||||
'EXTERNAL_ADMIN',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
iconType: null,
|
||||
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Theme preset type
|
||||
export interface ThemePreset {
|
||||
id: string
|
||||
colors: ThemeColors
|
||||
id: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
// Theme presets
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{ id: 'standard', colors: DEFAULT_THEME_COLORS },
|
||||
{ id: 'ocean', colors: { accent: '#0ea5e9', darkBackground: '#0c1222', darkSurface: '#1e293b', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', lightBackground: '#e0f2fe', lightSurface: '#f0f9ff', lightText: '#0c4a6e', lightTextSecondary: '#0369a1', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'forest', colors: { accent: '#22c55e', darkBackground: '#0a1a0f', darkSurface: '#14532d', darkText: '#f0fdf4', darkTextSecondary: '#86efac', lightBackground: '#dcfce7', lightSurface: '#f0fdf4', lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'sunset', colors: { accent: '#f97316', darkBackground: '#1c1009', darkSurface: '#2d1a0e', darkText: '#fff7ed', darkTextSecondary: '#fdba74', lightBackground: '#ffedd5', lightSurface: '#fff7ed', lightText: '#7c2d12', lightTextSecondary: '#c2410c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'violet', colors: { accent: '#a855f7', darkBackground: '#0f0a1a', darkSurface: '#1e1b2e', darkText: '#faf5ff', darkTextSecondary: '#c4b5fd', lightBackground: '#f3e8ff', lightSurface: '#faf5ff', lightText: '#581c87', lightTextSecondary: '#7e22ce', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'rose', colors: { accent: '#f43f5e', darkBackground: '#1a0a10', darkSurface: '#2d1520', darkText: '#fff1f2', darkTextSecondary: '#fda4af', lightBackground: '#ffe4e6', lightSurface: '#fff1f2', lightText: '#881337', lightTextSecondary: '#be123c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'midnight', colors: { accent: '#6366f1', darkBackground: '#030712', darkSurface: '#111827', darkText: '#f9fafb', darkTextSecondary: '#9ca3af', lightBackground: '#e5e7eb', lightSurface: '#f3f4f6', lightText: '#111827', lightTextSecondary: '#4b5563', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'turquoise', colors: { accent: '#14b8a6', darkBackground: '#0a1614', darkSurface: '#134e4a', darkText: '#f0fdfa', darkTextSecondary: '#5eead4', lightBackground: '#ccfbf1', lightSurface: '#f0fdfa', lightText: '#134e4a', lightTextSecondary: '#0f766e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
]
|
||||
{
|
||||
id: 'ocean',
|
||||
colors: {
|
||||
accent: '#0ea5e9',
|
||||
darkBackground: '#0c1222',
|
||||
darkSurface: '#1e293b',
|
||||
darkText: '#f1f5f9',
|
||||
darkTextSecondary: '#94a3b8',
|
||||
lightBackground: '#e0f2fe',
|
||||
lightSurface: '#f0f9ff',
|
||||
lightText: '#0c4a6e',
|
||||
lightTextSecondary: '#0369a1',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
colors: {
|
||||
accent: '#22c55e',
|
||||
darkBackground: '#0a1a0f',
|
||||
darkSurface: '#14532d',
|
||||
darkText: '#f0fdf4',
|
||||
darkTextSecondary: '#86efac',
|
||||
lightBackground: '#dcfce7',
|
||||
lightSurface: '#f0fdf4',
|
||||
lightText: '#14532d',
|
||||
lightTextSecondary: '#166534',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
colors: {
|
||||
accent: '#f97316',
|
||||
darkBackground: '#1c1009',
|
||||
darkSurface: '#2d1a0e',
|
||||
darkText: '#fff7ed',
|
||||
darkTextSecondary: '#fdba74',
|
||||
lightBackground: '#ffedd5',
|
||||
lightSurface: '#fff7ed',
|
||||
lightText: '#7c2d12',
|
||||
lightTextSecondary: '#c2410c',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'violet',
|
||||
colors: {
|
||||
accent: '#a855f7',
|
||||
darkBackground: '#0f0a1a',
|
||||
darkSurface: '#1e1b2e',
|
||||
darkText: '#faf5ff',
|
||||
darkTextSecondary: '#c4b5fd',
|
||||
lightBackground: '#f3e8ff',
|
||||
lightSurface: '#faf5ff',
|
||||
lightText: '#581c87',
|
||||
lightTextSecondary: '#7e22ce',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rose',
|
||||
colors: {
|
||||
accent: '#f43f5e',
|
||||
darkBackground: '#1a0a10',
|
||||
darkSurface: '#2d1520',
|
||||
darkText: '#fff1f2',
|
||||
darkTextSecondary: '#fda4af',
|
||||
lightBackground: '#ffe4e6',
|
||||
lightSurface: '#fff1f2',
|
||||
lightText: '#881337',
|
||||
lightTextSecondary: '#be123c',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
colors: {
|
||||
accent: '#6366f1',
|
||||
darkBackground: '#030712',
|
||||
darkSurface: '#111827',
|
||||
darkText: '#f9fafb',
|
||||
darkTextSecondary: '#9ca3af',
|
||||
lightBackground: '#e5e7eb',
|
||||
lightSurface: '#f3f4f6',
|
||||
lightText: '#111827',
|
||||
lightTextSecondary: '#4b5563',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'turquoise',
|
||||
colors: {
|
||||
accent: '#14b8a6',
|
||||
darkBackground: '#0a1614',
|
||||
darkSurface: '#134e4a',
|
||||
darkText: '#f0fdfa',
|
||||
darkTextSecondary: '#5eead4',
|
||||
lightBackground: '#ccfbf1',
|
||||
lightSurface: '#f0fdfa',
|
||||
lightText: '#134e4a',
|
||||
lightTextSecondary: '#0f766e',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,91 +1,141 @@
|
||||
// Admin Settings Icons
|
||||
|
||||
export const BackIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const StarIcon = ({ filled }: { filled?: boolean }) => (
|
||||
<svg className="w-5 h-5" fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const UploadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const TrashIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const PencilIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const RefreshIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const LockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const CloseIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const SunIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MoonIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MenuIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const EditIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Components
|
||||
export * from './icons'
|
||||
export * from './Toggle'
|
||||
export * from './SettingInput'
|
||||
export * from './SettingRow'
|
||||
export * from './AnalyticsTab'
|
||||
export * from './BrandingTab'
|
||||
export * from './ThemeTab'
|
||||
export * from './FavoritesTab'
|
||||
export * from './SettingsTab'
|
||||
export * from './SettingsSidebar'
|
||||
export * from './SettingsSearch'
|
||||
export * from './icons';
|
||||
export * from './Toggle';
|
||||
export * from './SettingInput';
|
||||
export * from './SettingRow';
|
||||
export * from './AnalyticsTab';
|
||||
export * from './BrandingTab';
|
||||
export * from './ThemeTab';
|
||||
export * from './FavoritesTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './SettingsSidebar';
|
||||
export * from './SettingsSearch';
|
||||
|
||||
// Constants and utils
|
||||
export * from './constants'
|
||||
export * from './utils'
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
// Format setting key from Snake_Case / CamelCase to readable text
|
||||
export function formatSettingKey(name: string): string {
|
||||
if (!name) return ''
|
||||
if (!name) return '';
|
||||
|
||||
return name
|
||||
// CamelCase -> spaces
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// snake_case -> spaces
|
||||
.replace(/_/g, ' ')
|
||||
// Remove extra spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Capitalize first letter
|
||||
.replace(/^./, c => c.toUpperCase())
|
||||
return (
|
||||
name
|
||||
// CamelCase -> spaces
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// snake_case -> spaces
|
||||
.replace(/_/g, ' ')
|
||||
// Remove extra spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Capitalize first letter
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Strip HTML tags and template descriptions from setting descriptions
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
if (!html) return '';
|
||||
const cleaned = html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
@@ -24,12 +26,12 @@ export function stripHtml(html: string): string {
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.trim()
|
||||
.trim();
|
||||
|
||||
// Remove template descriptions like "Параметр X управляет категорией Y"
|
||||
if (cleaned.match(/^Параметр .+ управляет категорией/)) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
return cleaned
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,99 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBlockingStore } from '../../store/blocking'
|
||||
import { apiClient } from '../../api/client'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
const CHECK_COOLDOWN_SECONDS = 5
|
||||
const CHECK_COOLDOWN_SECONDS = 5;
|
||||
|
||||
export default function ChannelSubscriptionScreen() {
|
||||
const { t } = useTranslation()
|
||||
const { channelInfo, clearBlocking } = useBlockingStore()
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [cooldown, setCooldown] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { t } = useTranslation();
|
||||
const { channelInfo, clearBlocking } = useBlockingStore();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cooldown timer
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return
|
||||
if (cooldown <= 0) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCooldown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [cooldown])
|
||||
return () => clearInterval(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const openChannel = useCallback(() => {
|
||||
if (channelInfo?.channel_link) {
|
||||
window.open(channelInfo.channel_link, '_blank')
|
||||
window.open(channelInfo.channel_link, '_blank');
|
||||
}
|
||||
}, [channelInfo?.channel_link])
|
||||
}, [channelInfo?.channel_link]);
|
||||
|
||||
const checkSubscription = useCallback(async () => {
|
||||
if (isChecking || cooldown > 0) return
|
||||
if (isChecking || cooldown > 0) return;
|
||||
|
||||
setIsChecking(true)
|
||||
setError(null)
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Make any authenticated request - if channel check passes, it will succeed
|
||||
await apiClient.get('/cabinet/auth/me')
|
||||
await apiClient.get('/cabinet/auth/me');
|
||||
// If we get here, subscription is valid - reload page
|
||||
clearBlocking()
|
||||
window.location.reload()
|
||||
clearBlocking();
|
||||
window.location.reload();
|
||||
} catch (err: unknown) {
|
||||
// Check if it's still a channel subscription error
|
||||
const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } }
|
||||
if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') {
|
||||
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'))
|
||||
const error = err as {
|
||||
response?: { status?: number; data?: { detail?: { code?: string } } };
|
||||
};
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.detail?.code === 'channel_subscription_required'
|
||||
) {
|
||||
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
|
||||
} else {
|
||||
// Other error - might be network issue
|
||||
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'))
|
||||
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
|
||||
}
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
setCooldown(CHECK_COOLDOWN_SECONDS)
|
||||
setIsChecking(false);
|
||||
setCooldown(CHECK_COOLDOWN_SECONDS);
|
||||
}
|
||||
}, [isChecking, cooldown, clearBlocking, t])
|
||||
}, [isChecking, cooldown, clearBlocking, t]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="w-24 h-24 mx-auto rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
|
||||
<svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-white mb-4">
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.channel.title', 'Подписка на канал')}
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-gray-400 mb-8 text-lg">
|
||||
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
||||
<p className="mb-8 text-lg text-gray-400">
|
||||
{channelInfo?.message ||
|
||||
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -101,14 +103,10 @@ export default function ChannelSubscriptionScreen() {
|
||||
<button
|
||||
onClick={openChannel}
|
||||
disabled={!channelInfo?.channel_link}
|
||||
className="w-full py-4 px-6 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
|
||||
className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
{t('blocking.channel.openChannel', 'Открыть канал')}
|
||||
</button>
|
||||
@@ -117,27 +115,60 @@ export default function ChannelSubscriptionScreen() {
|
||||
<button
|
||||
onClick={checkSubscription}
|
||||
disabled={isChecking || cooldown > 0}
|
||||
className="w-full py-4 px-6 bg-dark-800 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
|
||||
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('blocking.channel.checking', 'Проверяем...')}
|
||||
</>
|
||||
) : cooldown > 0 ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', { seconds: cooldown })}
|
||||
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', {
|
||||
seconds: cooldown,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
|
||||
</>
|
||||
@@ -146,10 +177,10 @@ export default function ChannelSubscriptionScreen() {
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<p className="text-gray-500 text-sm mt-6">
|
||||
<p className="mt-6 text-sm text-gray-500">
|
||||
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBlockingStore } from '../../store/blocking'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
|
||||
export default function MaintenanceScreen() {
|
||||
const { t } = useTranslation()
|
||||
const { maintenanceInfo } = useBlockingStore()
|
||||
const { t } = useTranslation();
|
||||
const { maintenanceInfo } = useBlockingStore();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="w-24 h-24 mx-auto rounded-full bg-dark-800 flex items-center justify-center">
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
|
||||
<svg
|
||||
className="w-12 h-12 text-amber-500"
|
||||
className="h-12 w-12 text-amber-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -28,38 +28,49 @@ export default function MaintenanceScreen() {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-white mb-4">
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.maintenance.title', 'Технические работы')}
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-gray-400 mb-6 text-lg">
|
||||
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')}
|
||||
<p className="mb-6 text-lg text-gray-400">
|
||||
{maintenanceInfo?.message ||
|
||||
t(
|
||||
'blocking.maintenance.defaultMessage',
|
||||
'Сервис временно недоступен. Проводятся технические работы.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Reason */}
|
||||
{maintenanceInfo?.reason && (
|
||||
<div className="bg-dark-800/50 rounded-xl p-4 mb-6">
|
||||
<p className="text-gray-500 text-sm mb-1">
|
||||
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-gray-500">
|
||||
{t('blocking.maintenance.reason', 'Причина')}:
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{maintenanceInfo.reason}
|
||||
</p>
|
||||
<p className="text-gray-300">{maintenanceInfo.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative dots */}
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '600ms' }} />
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
style={{ animationDelay: '600ms' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mt-4">
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as MaintenanceScreen } from './MaintenanceScreen'
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'
|
||||
export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
interface PageLoaderProps {
|
||||
variant?: 'dark' | 'light'
|
||||
variant?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50'
|
||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500'
|
||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50';
|
||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
|
||||
<div className={`w-10 h-10 border-[3px] ${spinnerColor} border-t-transparent rounded-full animate-spin`} />
|
||||
<div className={`flex min-h-screen items-center justify-center ${bgClass}`}>
|
||||
<div
|
||||
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,274 +1,346 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import LanguageSwitcher from '../LanguageSwitcher'
|
||||
import PromoDiscountBadge from '../PromoDiscountBadge'
|
||||
import TicketNotificationBell from '../TicketNotificationBell'
|
||||
import AnimatedBackground from '../AnimatedBackground'
|
||||
import { contestsApi } from '../../api/contests'
|
||||
import { pollsApi } from '../../api/polls'
|
||||
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded } from '../../api/branding'
|
||||
import { wheelApi } from '../../api/wheel'
|
||||
import { themeColorsApi } from '../../api/themeColors'
|
||||
import { promoApi } from '../../api/promo'
|
||||
import { referralApi } from '../../api/referral'
|
||||
import { useTheme } from '../../hooks/useTheme'
|
||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
||||
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../store/auth';
|
||||
import LanguageSwitcher from '../LanguageSwitcher';
|
||||
import PromoDiscountBadge from '../PromoDiscountBadge';
|
||||
import TicketNotificationBell from '../TicketNotificationBell';
|
||||
import AnimatedBackground from '../AnimatedBackground';
|
||||
import { contestsApi } from '../../api/contests';
|
||||
import { pollsApi } from '../../api/polls';
|
||||
import {
|
||||
brandingApi,
|
||||
getCachedBranding,
|
||||
setCachedBranding,
|
||||
preloadLogo,
|
||||
isLogoPreloaded,
|
||||
} from '../../api/branding';
|
||||
import { wheelApi } from '../../api/wheel';
|
||||
import { themeColorsApi } from '../../api/themeColors';
|
||||
import { promoApi } from '../../api/promo';
|
||||
import { referralApi } from '../../api/referral';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp';
|
||||
import { usePullToRefresh } from '../../hooks/usePullToRefresh';
|
||||
|
||||
// Fallback branding from environment variables
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Icons as simple SVG components
|
||||
const HomeIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SubscriptionIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChatIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UserIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const LogoutIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Theme toggle icons
|
||||
const SunIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MenuIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const GamepadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClipboardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const InfoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const WheelIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { user, logout, isAdmin, isAuthenticated } = useAuthStore()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false)
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||
|
||||
// Pull to refresh (disabled when mobile menu is open)
|
||||
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
|
||||
disabled: mobileMenuOpen,
|
||||
threshold: 80,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||
const { data: enabledThemes } = useQuery({
|
||||
queryKey: ['enabled-themes'],
|
||||
queryFn: themeColorsApi.getEnabledThemes,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
});
|
||||
|
||||
// Only show theme toggle if both themes are enabled
|
||||
const canToggle = enabledThemes?.dark && enabledThemes?.light
|
||||
const canToggle = enabledThemes?.dark && enabledThemes?.light;
|
||||
|
||||
// Get user photo from Telegram WebApp
|
||||
useEffect(() => {
|
||||
try {
|
||||
const tg = (window as any).Telegram?.WebApp
|
||||
const photoUrl = tg?.initDataUnsafe?.user?.photo_url
|
||||
const tg = (
|
||||
window as unknown as {
|
||||
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
|
||||
}
|
||||
).Telegram?.WebApp;
|
||||
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
|
||||
if (photoUrl) {
|
||||
setUserPhotoUrl(photoUrl)
|
||||
setUserPhotoUrl(photoUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to get Telegram user photo:', e)
|
||||
console.warn('Failed to get Telegram user photo:', e);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// Lock body scroll when mobile menu is open (cross-platform)
|
||||
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues
|
||||
// in Telegram Mini App where the menu disappears when opened from scrolled position
|
||||
useEffect(() => {
|
||||
if (!mobileMenuOpen) return
|
||||
if (!mobileMenuOpen) return;
|
||||
|
||||
const body = document.body
|
||||
const html = document.documentElement
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
// Save original styles
|
||||
const originalStyles = {
|
||||
bodyOverflow: body.style.overflow,
|
||||
htmlOverflow: html.style.overflow,
|
||||
}
|
||||
};
|
||||
|
||||
// Lock scroll - simple approach without body position manipulation
|
||||
body.style.overflow = 'hidden'
|
||||
html.style.overflow = 'hidden'
|
||||
body.style.overflow = 'hidden';
|
||||
html.style.overflow = 'hidden';
|
||||
|
||||
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement;
|
||||
// Allow scroll inside menu content
|
||||
if (target.closest('.mobile-menu-content')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||
if (target.closest('.mobile-menu-content')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
|
||||
// Also prevent wheel scroll on desktop
|
||||
const preventWheel = (e: WheelEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.mobile-menu-content')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.mobile-menu-content')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('wheel', preventWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
// Restore original styles
|
||||
body.style.overflow = originalStyles.bodyOverflow
|
||||
html.style.overflow = originalStyles.htmlOverflow
|
||||
body.style.overflow = originalStyles.bodyOverflow;
|
||||
html.style.overflow = originalStyles.htmlOverflow;
|
||||
|
||||
// Remove listeners
|
||||
document.removeEventListener('touchmove', preventScroll)
|
||||
document.removeEventListener('wheel', preventWheel)
|
||||
}
|
||||
}, [mobileMenuOpen])
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
document.removeEventListener('wheel', preventWheel);
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
// Detect virtual keyboard by tracking focus on input elements
|
||||
useEffect(() => {
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
setIsKeyboardOpen(true)
|
||||
setIsKeyboardOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||
// Only close if not focusing another input
|
||||
if (!relatedTarget ||
|
||||
(relatedTarget.tagName !== 'INPUT' &&
|
||||
relatedTarget.tagName !== 'TEXTAREA' &&
|
||||
!relatedTarget.isContentEditable)) {
|
||||
setIsKeyboardOpen(false)
|
||||
if (
|
||||
!relatedTarget ||
|
||||
(relatedTarget.tagName !== 'INPUT' &&
|
||||
relatedTarget.tagName !== 'TEXTAREA' &&
|
||||
!relatedTarget.isContentEditable)
|
||||
) {
|
||||
setIsKeyboardOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('focusin', handleFocusIn)
|
||||
document.addEventListener('focusout', handleFocusOut)
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
document.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', handleFocusIn)
|
||||
document.removeEventListener('focusout', handleFocusOut)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
document.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// State to track if logo image has loaded - start with true if already preloaded
|
||||
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded())
|
||||
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
|
||||
|
||||
// Fetch branding settings with localStorage cache for instant load
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: async () => {
|
||||
const data = await brandingApi.getBranding()
|
||||
setCachedBranding(data) // Update cache
|
||||
const data = await brandingApi.getBranding();
|
||||
setCachedBranding(data); // Update cache
|
||||
// Preload logo in background
|
||||
preloadLogo(data)
|
||||
return data
|
||||
preloadLogo(data);
|
||||
return data;
|
||||
},
|
||||
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
|
||||
staleTime: 60000, // 1 minute
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
})
|
||||
});
|
||||
|
||||
// Computed branding values - use fallback only if no branding and no cache
|
||||
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode)
|
||||
const logoLetter = branding?.logo_letter || FALLBACK_LOGO
|
||||
const hasCustomLogo = branding?.has_custom_logo || false
|
||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
||||
const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
|
||||
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
|
||||
const hasCustomLogo = branding?.has_custom_logo || false;
|
||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||
|
||||
// Set document title
|
||||
useEffect(() => {
|
||||
document.title = appName || 'VPN' // Fallback title if name is empty
|
||||
}, [appName])
|
||||
document.title = appName || 'VPN'; // Fallback title if name is empty
|
||||
}, [appName]);
|
||||
|
||||
// Fetch contests and polls counts to determine if they should be shown
|
||||
const { data: contestsCount } = useQuery({
|
||||
@@ -277,7 +349,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: pollsCount } = useQuery({
|
||||
queryKey: ['polls-count'],
|
||||
@@ -285,7 +357,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch wheel config to check if enabled
|
||||
const { data: wheelConfig } = useQuery({
|
||||
@@ -294,7 +366,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch referral terms to check if enabled
|
||||
const { data: referralTerms } = useQuery({
|
||||
@@ -303,7 +375,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch active discount to determine mobile layout
|
||||
const { data: activeDiscount } = useQuery({
|
||||
@@ -311,86 +383,90 @@ export default function Layout({ children }: LayoutProps) {
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Check if promo is active (to hide language switcher on mobile)
|
||||
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent
|
||||
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const items = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||
]
|
||||
];
|
||||
|
||||
// Only show referral if program is enabled
|
||||
if (referralTerms?.is_enabled) {
|
||||
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon })
|
||||
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon });
|
||||
}
|
||||
|
||||
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon })
|
||||
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon });
|
||||
|
||||
// Only show contests if there are available contests
|
||||
if (contestsCount && contestsCount.count > 0) {
|
||||
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon })
|
||||
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon });
|
||||
}
|
||||
|
||||
// Only show polls if there are available polls
|
||||
if (pollsCount && pollsCount.count > 0) {
|
||||
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon })
|
||||
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon });
|
||||
}
|
||||
|
||||
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon })
|
||||
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon });
|
||||
|
||||
return items
|
||||
}, [t, contestsCount, pollsCount, referralTerms])
|
||||
return items;
|
||||
}, [t, contestsCount, pollsCount, referralTerms]);
|
||||
|
||||
// Separate navItems for desktop that includes wheel (if enabled)
|
||||
const desktopNavItems = useMemo(() => {
|
||||
const items = [...navItems]
|
||||
const items = [...navItems];
|
||||
// Add wheel before info if enabled
|
||||
if (wheelConfig?.is_enabled) {
|
||||
const infoIndex = items.findIndex(item => item.path === '/info')
|
||||
const infoIndex = items.findIndex((item) => item.path === '/info');
|
||||
if (infoIndex !== -1) {
|
||||
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
|
||||
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
|
||||
} else {
|
||||
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
|
||||
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [navItems, wheelConfig, t])
|
||||
return items;
|
||||
}, [navItems, wheelConfig, t]);
|
||||
|
||||
const adminNavItems = [
|
||||
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
|
||||
]
|
||||
const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
const isAdminActive = () => location.pathname.startsWith('/admin')
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
const isAdminActive = () => location.pathname.startsWith('/admin');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Animated Background */}
|
||||
<AnimatedBackground />
|
||||
|
||||
{/* Pull to refresh indicator */}
|
||||
{(isPulling || isRefreshing) && (
|
||||
<div
|
||||
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200"
|
||||
className="fixed left-1/2 z-[100] flex -translate-x-1/2 items-center justify-center transition-all duration-200"
|
||||
style={{
|
||||
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
|
||||
opacity: isRefreshing ? 1 : progress,
|
||||
}}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dark-700 bg-dark-800 shadow-lg ${isRefreshing ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
className={`h-5 w-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,19 +474,30 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
{/* Header */}
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10"
|
||||
className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10"
|
||||
style={{
|
||||
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
||||
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
|
||||
paddingTop: isFullscreen
|
||||
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}>
|
||||
<div className="flex justify-between items-center h-16 lg:h-20">
|
||||
<div
|
||||
className="mx-auto w-full px-4 sm:px-6"
|
||||
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between lg:h-20">
|
||||
{/* Logo */}
|
||||
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
||||
<div className="w-10 h-10 sm:w-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`}
|
||||
>
|
||||
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md dark:bg-dark-800/80 sm:h-11 sm:w-11 lg:h-12 lg:w-12">
|
||||
{/* Always show letter as fallback */}
|
||||
<span className={`text-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<span
|
||||
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 sm:text-xl ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
{logoLetter}
|
||||
</span>
|
||||
{/* Logo image with smooth fade-in */}
|
||||
@@ -418,28 +505,28 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName || 'Logo'}
|
||||
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appName && (
|
||||
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap">
|
||||
<span className="whitespace-nowrap text-base font-semibold text-dark-100 lg:text-lg">
|
||||
{appName}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex items-center gap-1">
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{desktopNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
isActive(item.path)
|
||||
? 'text-accent-400 bg-accent-500/10'
|
||||
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50'
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon />
|
||||
@@ -448,15 +535,15 @@ export default function Layout({ children }: LayoutProps) {
|
||||
))}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-dark-700 mx-2" />
|
||||
<div className="mx-2 h-6 w-px bg-dark-700" />
|
||||
{adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
isAdminActive()
|
||||
? 'text-warning-400 bg-warning-500/10'
|
||||
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10'
|
||||
? 'bg-warning-500/10 text-warning-400'
|
||||
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
|
||||
}`}
|
||||
>
|
||||
<item.icon />
|
||||
@@ -473,21 +560,26 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{canToggle && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleTheme()
|
||||
setMobileMenuOpen(false)
|
||||
toggleTheme();
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="relative p-2 rounded-xl transition-all duration-200
|
||||
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
|
||||
dark:text-dark-400 dark:hover:text-accent-400
|
||||
text-champagne-500 hover:text-champagne-800"
|
||||
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-champagne-500 transition-all duration-200 hover:bg-dark-700 hover:text-champagne-800 dark:text-dark-400 dark:hover:text-accent-400"
|
||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
|
||||
aria-label={
|
||||
isDark
|
||||
? t('theme.light') || 'Switch to light mode'
|
||||
: t('theme.dark') || 'Switch to dark mode'
|
||||
}
|
||||
>
|
||||
<div className="relative w-5 h-5">
|
||||
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
|
||||
<div className="relative h-5 w-5">
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-300 ${isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'}`}
|
||||
>
|
||||
<MoonIcon />
|
||||
</div>
|
||||
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}>
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-300 ${isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100'}`}
|
||||
>
|
||||
<SunIcon />
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,17 +593,20 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<TicketNotificationBell isAdmin={isAdminActive()} />
|
||||
</div>
|
||||
{/* Hide language switcher on mobile when promo is active */}
|
||||
<div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}>
|
||||
<div
|
||||
className={isPromoActive ? 'hidden sm:block' : ''}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Profile - Desktop */}
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
<div className="hidden items-center gap-3 sm:flex">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<span className="text-sm text-dark-300">
|
||||
@@ -531,11 +626,13 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMobileMenuOpen(!mobileMenuOpen)
|
||||
e.stopPropagation();
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
}}
|
||||
className="lg:hidden btn-icon"
|
||||
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
|
||||
className="btn-icon lg:hidden"
|
||||
aria-label={
|
||||
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
|
||||
}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
@@ -543,27 +640,26 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
{/* Spacer for fixed header - matches header height */}
|
||||
{isFullscreen ? (
|
||||
<div
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-shrink-0 h-16 lg:h-20" />
|
||||
<div className="h-16 flex-shrink-0 lg:h-20" />
|
||||
)}
|
||||
|
||||
{/* Mobile menu - fixed overlay below header */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in"
|
||||
className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
|
||||
style={{
|
||||
top: isFullscreen
|
||||
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
||||
: '64px'
|
||||
: '64px',
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
@@ -573,23 +669,28 @@ export default function Layout({ children }: LayoutProps) {
|
||||
/>
|
||||
|
||||
{/* Menu content */}
|
||||
<div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-4">
|
||||
<div
|
||||
className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-4 py-4">
|
||||
{/* User info */}
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{userPhotoUrl ? (
|
||||
<img
|
||||
src={userPhotoUrl}
|
||||
alt="Avatar"
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden')
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full bg-dark-700 ${userPhotoUrl ? 'hidden' : ''}`}
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
<div>
|
||||
@@ -622,7 +723,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="divider my-3" />
|
||||
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider">
|
||||
<div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.nav.title')}
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
@@ -630,7 +731,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`}
|
||||
className={`nav-item ${isAdminActive() ? 'bg-warning-500/10 text-warning-400' : 'text-warning-500/70'}`}
|
||||
>
|
||||
<item.icon />
|
||||
{item.label}
|
||||
@@ -652,8 +753,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false)
|
||||
logout()
|
||||
setMobileMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="nav-item w-full text-error-400"
|
||||
>
|
||||
@@ -667,31 +768,32 @@ export default function Layout({ children }: LayoutProps) {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8">
|
||||
<div className="animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:px-6 lg:pb-8">
|
||||
<div className="animate-fade-in">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
|
||||
<nav className={`bottom-nav lg:hidden transition-opacity duration-200 ${isKeyboardOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
||||
<nav
|
||||
className={`bottom-nav transition-opacity duration-200 lg:hidden ${isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div className="flex justify-around">
|
||||
{navItems.filter(item =>
|
||||
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path)
|
||||
).map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{navItems
|
||||
.filter((item) =>
|
||||
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
|
||||
)
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="mt-1 whitespace-nowrap text-2xs">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { forwardRef } from 'react'
|
||||
import { Link } from 'react-router-dom';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
interface BentoCardBaseProps {
|
||||
size?: BentoSize
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
size?: BentoSize;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
interface BentoCardDivProps extends BentoCardBaseProps {
|
||||
as?: 'div'
|
||||
onClick?: () => void
|
||||
as?: 'div';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface BentoCardLinkProps extends BentoCardBaseProps {
|
||||
as: 'link'
|
||||
to: string
|
||||
state?: unknown
|
||||
as: 'link';
|
||||
to: string;
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
interface BentoCardButtonProps extends BentoCardBaseProps {
|
||||
as: 'button'
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
as: 'button';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
}
|
||||
|
||||
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps
|
||||
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps;
|
||||
|
||||
const sizeClasses: Record<BentoSize, string> = {
|
||||
sm: '',
|
||||
md: 'col-span-2',
|
||||
lg: 'row-span-2',
|
||||
xl: 'col-span-2 row-span-2',
|
||||
}
|
||||
};
|
||||
|
||||
const baseClasses = `
|
||||
bento-card
|
||||
@@ -45,7 +45,7 @@ const baseClasses = `
|
||||
bg-dark-900/70
|
||||
border border-dark-700/40
|
||||
transition-all duration-300 ease-smooth
|
||||
`
|
||||
`;
|
||||
|
||||
const hoverClasses = `
|
||||
cursor-pointer
|
||||
@@ -54,21 +54,15 @@ const hoverClasses = `
|
||||
hover:shadow-lg
|
||||
hover:scale-[1.01]
|
||||
active:scale-[0.99]
|
||||
`
|
||||
`;
|
||||
|
||||
const glowClasses = `
|
||||
hover:shadow-glow
|
||||
hover:border-accent-500/30
|
||||
`
|
||||
`;
|
||||
|
||||
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
||||
const {
|
||||
size = 'sm',
|
||||
children,
|
||||
className = '',
|
||||
hover = false,
|
||||
glow = false,
|
||||
} = props
|
||||
const { size = 'sm', children, className = '', hover = false, glow = false } = props;
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
@@ -76,19 +70,21 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
||||
hover && hoverClasses,
|
||||
glow && glowClasses,
|
||||
className,
|
||||
].filter(Boolean).join(' ')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
if (props.as === 'link') {
|
||||
const { to, state } = props as BentoCardLinkProps
|
||||
const { to, state } = props as BentoCardLinkProps;
|
||||
return (
|
||||
<Link to={to} state={state} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (props.as === 'button') {
|
||||
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps
|
||||
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps;
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
@@ -99,17 +95,17 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { onClick } = props as BentoCardDivProps
|
||||
const { onClick } = props as BentoCardDivProps;
|
||||
return (
|
||||
<div ref={ref} onClick={onClick} className={classes}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
BentoCard.displayName = 'BentoCard'
|
||||
BentoCard.displayName = 'BentoCard';
|
||||
|
||||
export default BentoCard
|
||||
export default BentoCard;
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
interface BentoSkeletonProps {
|
||||
className?: string
|
||||
count?: number
|
||||
className?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
|
||||
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`
|
||||
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`;
|
||||
|
||||
if (count > 1) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={baseClasses}
|
||||
style={{ '--stagger': i } as React.CSSProperties}
|
||||
/>
|
||||
<div key={i} className={baseClasses} style={{ '--stagger': i } as React.CSSProperties} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={baseClasses}
|
||||
/>
|
||||
)
|
||||
return <div className={baseClasses} />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useEffect, useRef, useState, useMemo, memo } from 'react'
|
||||
import type { WheelPrize } from '../../api/wheel'
|
||||
import { useEffect, useRef, useState, useMemo, memo } from 'react';
|
||||
import type { WheelPrize } from '../../api/wheel';
|
||||
|
||||
interface FortuneWheelProps {
|
||||
prizes: WheelPrize[]
|
||||
isSpinning: boolean
|
||||
targetRotation: number | null
|
||||
onSpinComplete: () => void
|
||||
prizes: WheelPrize[];
|
||||
isSpinning: boolean;
|
||||
targetRotation: number | null;
|
||||
onSpinComplete: () => void;
|
||||
}
|
||||
|
||||
// Pre-generate sparkle positions to avoid recalculating on each render
|
||||
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
|
||||
top: `${20 + (i * 10) % 60}%`,
|
||||
left: `${15 + (i * 13) % 70}%`,
|
||||
top: `${20 + ((i * 10) % 60)}%`,
|
||||
left: `${15 + ((i * 13) % 70)}%`,
|
||||
delay: `${i * 0.15}s`,
|
||||
}))
|
||||
}));
|
||||
|
||||
const FortuneWheel = memo(function FortuneWheel({
|
||||
prizes,
|
||||
@@ -21,141 +21,150 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
targetRotation,
|
||||
onSpinComplete,
|
||||
}: FortuneWheelProps) {
|
||||
const wheelRef = useRef<SVGGElement>(null)
|
||||
const [currentRotation, setCurrentRotation] = useState(0)
|
||||
const [lightPhase, setLightPhase] = useState(0)
|
||||
const wheelRef = useRef<SVGGElement>(null);
|
||||
const [currentRotation, setCurrentRotation] = useState(0);
|
||||
const [lightPhase, setLightPhase] = useState(0);
|
||||
|
||||
// Animated lights effect - use phase instead of random array (less re-renders)
|
||||
useEffect(() => {
|
||||
if (isSpinning) {
|
||||
const interval = setInterval(() => {
|
||||
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
|
||||
}, 250) // Slower interval = better performance
|
||||
return () => clearInterval(interval)
|
||||
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
||||
}, 250); // Slower interval = better performance
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setLightPhase(0)
|
||||
setLightPhase(0);
|
||||
}
|
||||
}, [isSpinning])
|
||||
}, [isSpinning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
||||
setCurrentRotation(targetRotation)
|
||||
setCurrentRotation(targetRotation);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
onSpinComplete()
|
||||
}, 5000)
|
||||
onSpinComplete();
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isSpinning, targetRotation, onSpinComplete])
|
||||
}, [isSpinning, targetRotation, onSpinComplete]);
|
||||
|
||||
// Memoize light pattern calculation
|
||||
const lightPattern = useMemo(() => {
|
||||
return Array.from({ length: 20 }, (_, i) => {
|
||||
if (!isSpinning) return i % 2 === 0
|
||||
return (i + lightPhase) % 3 !== 0
|
||||
})
|
||||
}, [isSpinning, lightPhase])
|
||||
if (!isSpinning) return i % 2 === 0;
|
||||
return (i + lightPhase) % 3 !== 0;
|
||||
});
|
||||
}, [isSpinning, lightPhase]);
|
||||
|
||||
if (prizes.length === 0) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
|
||||
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
|
||||
<p className="text-dark-400">No prizes configured</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const size = 400
|
||||
const center = size / 2
|
||||
const outerRadius = size / 2 - 20
|
||||
const innerRadius = outerRadius - 15
|
||||
const prizeRadius = innerRadius - 5
|
||||
const sectorAngle = 360 / prizes.length
|
||||
const hubRadius = 45
|
||||
const size = 400;
|
||||
const center = size / 2;
|
||||
const outerRadius = size / 2 - 20;
|
||||
const innerRadius = outerRadius - 15;
|
||||
const prizeRadius = innerRadius - 5;
|
||||
const sectorAngle = 360 / prizes.length;
|
||||
const hubRadius = 45;
|
||||
|
||||
const createSectorPath = (index: number) => {
|
||||
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180)
|
||||
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180)
|
||||
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = center + prizeRadius * Math.cos(startAngle)
|
||||
const y1 = center + prizeRadius * Math.sin(startAngle)
|
||||
const x2 = center + prizeRadius * Math.cos(endAngle)
|
||||
const y2 = center + prizeRadius * Math.sin(endAngle)
|
||||
const x1 = center + prizeRadius * Math.cos(startAngle);
|
||||
const y1 = center + prizeRadius * Math.sin(startAngle);
|
||||
const x2 = center + prizeRadius * Math.cos(endAngle);
|
||||
const y2 = center + prizeRadius * Math.sin(endAngle);
|
||||
|
||||
const x1Inner = center + hubRadius * Math.cos(startAngle)
|
||||
const y1Inner = center + hubRadius * Math.sin(startAngle)
|
||||
const x2Inner = center + hubRadius * Math.cos(endAngle)
|
||||
const y2Inner = center + hubRadius * Math.sin(endAngle)
|
||||
const x1Inner = center + hubRadius * Math.cos(startAngle);
|
||||
const y1Inner = center + hubRadius * Math.sin(startAngle);
|
||||
const x2Inner = center + hubRadius * Math.cos(endAngle);
|
||||
const y2Inner = center + hubRadius * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sectorAngle > 180 ? 1 : 0
|
||||
const largeArc = sectorAngle > 180 ? 1 : 0;
|
||||
|
||||
return `M ${x1Inner} ${y1Inner}
|
||||
L ${x1} ${y1}
|
||||
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
|
||||
L ${x2Inner} ${y2Inner}
|
||||
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`
|
||||
}
|
||||
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`;
|
||||
};
|
||||
|
||||
// Position for emoji - closer to outer edge
|
||||
const getEmojiPosition = (index: number) => {
|
||||
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
|
||||
const emojiRadius = prizeRadius * 0.75
|
||||
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
||||
const emojiRadius = prizeRadius * 0.75;
|
||||
return {
|
||||
x: center + emojiRadius * Math.cos(angle),
|
||||
y: center + emojiRadius * Math.sin(angle),
|
||||
rotation: index * sectorAngle + sectorAngle / 2,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Position for text - between hub and emoji
|
||||
const getTextPosition = (index: number) => {
|
||||
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
|
||||
const textRadius = prizeRadius * 0.45
|
||||
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
||||
const textRadius = prizeRadius * 0.45;
|
||||
return {
|
||||
x: center + textRadius * Math.cos(angle),
|
||||
y: center + textRadius * Math.sin(angle),
|
||||
rotation: index * sectorAngle + sectorAngle / 2,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Alternate colors for sectors
|
||||
const getSectorColors = (index: number, baseColor?: string) => {
|
||||
if (baseColor) return baseColor
|
||||
if (baseColor) return baseColor;
|
||||
const colors = [
|
||||
'#8B5CF6', '#EC4899', '#3B82F6', '#10B981',
|
||||
'#F59E0B', '#EF4444', '#6366F1', '#14B8A6'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
'#8B5CF6',
|
||||
'#EC4899',
|
||||
'#3B82F6',
|
||||
'#10B981',
|
||||
'#F59E0B',
|
||||
'#EF4444',
|
||||
'#6366F1',
|
||||
'#14B8A6',
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
// Truncate text intelligently
|
||||
const truncateText = (text: string, maxLen: number) => {
|
||||
if (text.length <= maxLen) return text
|
||||
return text.substring(0, maxLen - 1) + '..'
|
||||
}
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.substring(0, maxLen - 1) + '..';
|
||||
};
|
||||
|
||||
// Calculate max text length based on number of sectors
|
||||
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8
|
||||
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8;
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[380px] mx-auto select-none">
|
||||
<div className="relative mx-auto w-full max-w-[380px] select-none">
|
||||
{/* Outer glow effect */}
|
||||
<div
|
||||
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
|
||||
isSpinning ? 'opacity-100 scale-105' : 'opacity-60'
|
||||
isSpinning ? 'scale-105 opacity-100' : 'opacity-60'
|
||||
}`}
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
|
||||
filter: 'blur(25px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pointer */}
|
||||
<div className="absolute top-[-12px] left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="absolute left-1/2 top-[-12px] z-20 -translate-x-1/2">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
|
||||
style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)' }}
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
|
||||
<defs>
|
||||
@@ -166,7 +175,13 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
<stop offset="100%" stopColor="#D97706" />
|
||||
</linearGradient>
|
||||
<filter id="pointerGlow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#F59E0B" floodOpacity="0.6"/>
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="2"
|
||||
stdDeviation="3"
|
||||
floodColor="#F59E0B"
|
||||
floodOpacity="0.6"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<polygon
|
||||
@@ -174,35 +189,35 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
fill="url(#pointerGold)"
|
||||
filter="url(#pointerGlow)"
|
||||
/>
|
||||
<polygon
|
||||
points="22,50 6,16 22,4"
|
||||
fill="rgba(255,255,255,0.3)"
|
||||
/>
|
||||
<circle cx="22" cy="24" r="8" fill="#FEF3C7"/>
|
||||
<circle cx="22" cy="24" r="5" fill="#FBBF24"/>
|
||||
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8"/>
|
||||
<polygon points="22,50 6,16 22,4" fill="rgba(255,255,255,0.3)" />
|
||||
<circle cx="22" cy="24" r="8" fill="#FEF3C7" />
|
||||
<circle cx="22" cy="24" r="5" fill="#FBBF24" />
|
||||
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Wheel */}
|
||||
<div className="relative aspect-square">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full">
|
||||
<defs>
|
||||
{/* Sector gradients */}
|
||||
{prizes.map((prize, index) => {
|
||||
const color = getSectorColors(index, prize.color)
|
||||
const color = getSectorColors(index, prize.color);
|
||||
return (
|
||||
<linearGradient
|
||||
key={`grad-${index}`}
|
||||
id={`sectorGrad-${index}`}
|
||||
x1="0%" y1="0%" x2="100%" y2="100%"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
||||
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring gradient */}
|
||||
@@ -223,7 +238,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* Text shadow filter */}
|
||||
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7"/>
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
@@ -252,10 +267,10 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* LED lights on outer ring */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i * 18 - 90) * (Math.PI / 180)
|
||||
const dotX = center + outerRadius * Math.cos(angle)
|
||||
const dotY = center + outerRadius * Math.sin(angle)
|
||||
const isLit = lightPattern[i] ?? (i % 2 === 0)
|
||||
const angle = (i * 18 - 90) * (Math.PI / 180);
|
||||
const dotX = center + outerRadius * Math.cos(angle);
|
||||
const dotY = center + outerRadius * Math.sin(angle);
|
||||
const isLit = lightPattern[i] ?? i % 2 === 0;
|
||||
return (
|
||||
<g key={`led-${i}`}>
|
||||
{isLit && (
|
||||
@@ -277,7 +292,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rotating wheel group */}
|
||||
@@ -286,9 +301,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
style={{
|
||||
transformOrigin: `${center}px ${center}px`,
|
||||
transform: `rotate(${currentRotation}deg)`,
|
||||
transition: isSpinning
|
||||
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
|
||||
: 'none',
|
||||
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Sectors */}
|
||||
@@ -304,11 +317,11 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* Sector dividers */}
|
||||
{prizes.map((_, index) => {
|
||||
const angle = (index * sectorAngle - 90) * (Math.PI / 180)
|
||||
const x1 = center + hubRadius * Math.cos(angle)
|
||||
const y1 = center + hubRadius * Math.sin(angle)
|
||||
const x2 = center + prizeRadius * Math.cos(angle)
|
||||
const y2 = center + prizeRadius * Math.sin(angle)
|
||||
const angle = (index * sectorAngle - 90) * (Math.PI / 180);
|
||||
const x1 = center + hubRadius * Math.cos(angle);
|
||||
const y1 = center + hubRadius * Math.sin(angle);
|
||||
const x2 = center + prizeRadius * Math.cos(angle);
|
||||
const y2 = center + prizeRadius * Math.sin(angle);
|
||||
return (
|
||||
<line
|
||||
key={`divider-${index}`}
|
||||
@@ -319,12 +332,12 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Prize content - Emoji */}
|
||||
{prizes.map((prize, index) => {
|
||||
const pos = getEmojiPosition(index)
|
||||
const pos = getEmojiPosition(index);
|
||||
return (
|
||||
<text
|
||||
key={`emoji-${prize.id}`}
|
||||
@@ -332,19 +345,19 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={prizes.length <= 6 ? "32" : "26"}
|
||||
fontSize={prizes.length <= 6 ? '32' : '26'}
|
||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
||||
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
|
||||
>
|
||||
{prize.emoji}
|
||||
</text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Prize content - Text */}
|
||||
{prizes.map((prize, index) => {
|
||||
const pos = getTextPosition(index)
|
||||
const displayText = truncateText(prize.display_name, maxTextLength)
|
||||
const pos = getTextPosition(index);
|
||||
const displayText = truncateText(prize.display_name, maxTextLength);
|
||||
return (
|
||||
<text
|
||||
key={`text-${prize.id}`}
|
||||
@@ -352,7 +365,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={prizes.length <= 4 ? "13" : prizes.length <= 6 ? "11" : "9"}
|
||||
fontSize={prizes.length <= 4 ? '13' : prizes.length <= 6 ? '11' : '9'}
|
||||
fontWeight="700"
|
||||
fill="#FFFFFF"
|
||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
||||
@@ -361,7 +374,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
@@ -386,13 +399,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
/>
|
||||
|
||||
{/* Hub shine */}
|
||||
<ellipse
|
||||
cx={center - 10}
|
||||
cy={center - 12}
|
||||
rx={15}
|
||||
ry={10}
|
||||
fill="rgba(255,255,255,0.2)"
|
||||
/>
|
||||
<ellipse cx={center - 10} cy={center - 12} rx={15} ry={10} fill="rgba(255,255,255,0.2)" />
|
||||
|
||||
{/* Center button */}
|
||||
<circle
|
||||
@@ -422,7 +429,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
{/* Spinning overlay glow */}
|
||||
{isSpinning && (
|
||||
<div
|
||||
className="absolute inset-0 rounded-full pointer-events-none"
|
||||
className="pointer-events-none absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
|
||||
animation: 'pulse 0.5s ease-in-out infinite',
|
||||
@@ -433,11 +440,11 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
|
||||
{isSpinning && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{SPARKLE_POSITIONS.map((pos, i) => (
|
||||
<div
|
||||
key={`sparkle-${i}`}
|
||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
|
||||
className="absolute h-2 w-2 animate-ping rounded-full bg-yellow-300"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
@@ -450,7 +457,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export default FortuneWheel
|
||||
export default FortuneWheel;
|
||||
|
||||
Reference in New Issue
Block a user