diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index e841b7a..6b90673 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' interface ColorPickerProps { value: string @@ -8,6 +8,28 @@ interface ColorPickerProps { disabled?: boolean } +// Check if running in Telegram WebApp (native color picker causes crash) +const isTelegramWebApp = (): boolean => { + 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) + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { 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('') +} + const PRESET_COLORS = [ '#3b82f6', // Blue '#ef4444', // Red @@ -21,18 +43,36 @@ const PRESET_COLORS = [ '#f97316', // Orange '#6366f1', // Indigo '#a855f7', // Purple + '#ffffff', // White + '#64748b', // Slate + '#1e293b', // Dark + '#000000', // Black ] export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { const [isOpen, setIsOpen] = useState(false) const [localValue, setLocalValue] = useState(value) + const [rgb, setRgb] = useState(() => hexToRgb(value)) const containerRef = useRef(null) const colorInputRef = useRef(null) + // Memoize Telegram check to avoid recalculating + const isTelegram = useMemo(() => isTelegramWebApp(), []) + useEffect(() => { setLocalValue(value) + setRgb(hexToRgb(value)) }, [value]) + // Handle RGB slider change + const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => { + const newRgb = { ...rgb, [channel]: val } + setRgb(newRgb) + const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b) + setLocalValue(hex) + onChange(hex) + }, [rgb, onChange]) + // Close on outside click useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -103,46 +143,107 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C maxLength={7} /> - {/* Hidden native color picker for direct access */} - - - {/* Native picker button - min 44px for touch accessibility */} - + + {/* Native picker button - min 44px for touch accessibility */} + + + )} - {/* Dropdown with presets */} + {/* Dropdown with presets and RGB sliders */} {isOpen && ( -
+
+ {/* RGB Sliders - shown in Telegram instead of native picker */} + {isTelegram && ( +
+
RGB
+
+ {/* Red */} +
+ R + handleRgbChange('r', parseInt(e.target.value))} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, + }} + /> + {rgb.r} +
+ {/* Green */} +
+ G + handleRgbChange('g', parseInt(e.target.value))} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, + }} + /> + {rgb.g} +
+ {/* Blue */} +
+ B + handleRgbChange('b', parseInt(e.target.value))} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, + }} + /> + {rgb.b} +
+
+
+ )} +
Preset colors
-
+
{PRESET_COLORS.map((preset) => ( {/* Dropdown - mobile: fixed centered, desktop: absolute right */} diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index b2cd7da..57f9f61 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -6,6 +6,7 @@ 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 = () => ( @@ -32,6 +33,12 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi const { showToast } = useToast() const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(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 // Show toast for WebSocket notification const showWSNotificationToast = useCallback((message: WSMessage) => { @@ -204,7 +211,12 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {/* Dropdown */} {isOpen && ( -
+
{/* Header */}

diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index e7af97f..0c82aee 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -142,7 +142,7 @@ export default function Layout({ children }: LayoutProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const { toggleTheme, isDark } = useTheme() const [userPhotoUrl, setUserPhotoUrl] = useState(null) - const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen } = useTelegramWebApp() + const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ @@ -300,7 +300,13 @@ export default function Layout({ children }: LayoutProps) { {/* Header */} -
+
{/* Logo */} diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index 6f2b9a0..e8402b4 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -8,6 +8,7 @@ export function useTelegramWebApp() { const [isFullscreen, setIsFullscreen] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) + const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined @@ -20,6 +21,7 @@ export function useTelegramWebApp() { setIsTelegramWebApp(true) setIsFullscreen(webApp.isFullscreen || false) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) // Expand WebApp to full height webApp.expand() @@ -29,12 +31,23 @@ export function useTelegramWebApp() { const handleFullscreenChanged = () => { setIsFullscreen(webApp.isFullscreen || false) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + } + + // Listen for safe area changes + const handleSafeAreaChanged = () => { + setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) } webApp.onEvent('fullscreenChanged', handleFullscreenChanged) + webApp.onEvent('safeAreaChanged', handleSafeAreaChanged) + webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged) return () => { webApp.offEvent('fullscreenChanged', handleFullscreenChanged) + webApp.offEvent('safeAreaChanged', handleSafeAreaChanged) + webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged) } }, [webApp]) @@ -86,6 +99,7 @@ export function useTelegramWebApp() { isFullscreen, isFullscreenSupported, safeAreaInset, + contentSafeAreaInset, requestFullscreen, exitFullscreen, toggleFullscreen, diff --git a/src/styles/globals.css b/src/styles/globals.css index 2deee88..408b8e1 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -102,19 +102,34 @@ html { overflow-x: hidden; - scroll-behavior: smooth; + } + + /* Smooth scroll only on desktop - mobile uses native */ + @media (min-width: 1024px) { + html { + scroll-behavior: smooth; + } } /* Dark theme (default) */ body, .dark body { @apply bg-dark-950 text-dark-100 font-sans antialiased; overscroll-behavior-y: contain; + /* iOS smooth scrolling */ + -webkit-overflow-scrolling: touch; + } + + /* Optimize main scrollable content */ + main { + /* Prevent layout shifts during scroll */ + contain: layout style; } /* Light theme - Champagne */ .light body { @apply bg-champagne-200 text-champagne-900 font-sans antialiased; overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; } /* Light theme text color overrides - convert dark-* text colors to readable colors */ @@ -310,10 +325,19 @@ @layer components { /* ========== DARK THEME COMPONENTS (default) ========== */ - /* Cards - Dark */ + /* Cards - Dark (optimized for mobile) */ .card { - @apply bg-dark-900/50 backdrop-blur-sm rounded-2xl border border-dark-800/50 p-5 sm:p-6 + @apply bg-dark-900/70 rounded-2xl border border-dark-800/50 p-5 sm:p-6 transition-all duration-300 ease-smooth; + /* GPU acceleration for smooth scroll */ + transform: translateZ(0); + } + + /* Enable backdrop-blur only on desktop */ + @media (min-width: 1024px) { + .card { + @apply bg-dark-900/50 backdrop-blur-sm; + } } .card-hover { @@ -329,9 +353,19 @@ @apply card animate-slide-up; } - /* Glass effect - Dark */ + /* Glass effect - Dark (optimized for mobile) */ .glass { - @apply bg-dark-900/30 backdrop-blur-xl border border-dark-700/30; + @apply bg-dark-900/80 border border-dark-700/30; + /* GPU acceleration */ + transform: translateZ(0); + will-change: transform; + } + + /* Enable backdrop-blur only on desktop where it's performant */ + @media (min-width: 1024px) { + .glass { + @apply bg-dark-900/30 backdrop-blur-xl; + } } /* Buttons - Dark */ @@ -455,11 +489,18 @@ @apply nav-item text-accent-400 bg-accent-500/10; } - /* Bottom nav - Dark */ + /* Bottom nav - Dark (optimized for mobile) */ .bottom-nav { @apply fixed bottom-0 left-0 right-0 z-50 - bg-dark-900/80 backdrop-blur-xl border-t border-dark-800/50 + bg-dark-900/95 border-t border-dark-800/50 safe-area-pb; + transform: translateZ(0); + } + + @media (min-width: 1024px) { + .bottom-nav { + @apply bg-dark-900/80 backdrop-blur-xl; + } } .bottom-nav-item { @@ -476,12 +517,34 @@ @apply border-t border-dark-800/50; } + /* ========== MOBILE PERFORMANCE: Disable backdrop-blur ========== */ + @media (max-width: 1023px) { + /* Disable all backdrop-blur on mobile for better scroll performance */ + .backdrop-blur, + .backdrop-blur-sm, + .backdrop-blur-md, + .backdrop-blur-lg, + .backdrop-blur-xl, + .backdrop-blur-2xl, + .backdrop-blur-3xl { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + } + /* ========== LIGHT THEME COMPONENTS (Champagne) ========== */ /* Cards - Light */ .light .card { - @apply bg-white/80 backdrop-blur-sm rounded-2xl border border-champagne-300/50 p-5 sm:p-6 + @apply bg-white/90 rounded-2xl border border-champagne-300/50 p-5 sm:p-6 shadow-sm; + transform: translateZ(0); + } + + @media (min-width: 1024px) { + .light .card { + @apply bg-white/80 backdrop-blur-sm; + } } .light .card-hover { @@ -492,9 +555,16 @@ @apply hover:shadow-lg hover:border-accent-400/40; } - /* Glass effect - Light */ + /* Glass effect - Light (optimized for mobile) */ .light .glass { - @apply bg-white/60 backdrop-blur-xl border border-champagne-300/40; + @apply bg-white/90 border border-champagne-300/40; + transform: translateZ(0); + } + + @media (min-width: 1024px) { + .light .glass { + @apply bg-white/60 backdrop-blur-xl; + } } /* Buttons - Light */ @@ -884,6 +954,37 @@ } } +/* Color picker range input styling */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 8px; + border-radius: 4px; + outline: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: white; + cursor: pointer; + border: 2px solid rgba(0, 0, 0, 0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: white; + cursor: pointer; + border: 2px solid rgba(0, 0, 0, 0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + /* Selection color */ ::selection { @apply bg-accent-500/30 text-dark-50;