mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Add pull to refresh functionality in Layout component: integrate usePullToRefresh hook, implement visual indicator for refresh state, and ensure compatibility with mobile menu interactions for improved user experience.
This commit is contained in:
@@ -15,6 +15,7 @@ import { themeColorsApi } from '../../api/themeColors'
|
|||||||
import { promoApi } from '../../api/promo'
|
import { promoApi } from '../../api/promo'
|
||||||
import { useTheme } from '../../hooks/useTheme'
|
import { useTheme } from '../../hooks/useTheme'
|
||||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
||||||
|
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
|
||||||
|
|
||||||
// Fallback branding from environment variables
|
// Fallback branding from environment variables
|
||||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
||||||
@@ -132,6 +133,12 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
||||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
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
|
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||||
const { data: enabledThemes } = useQuery({
|
const { data: enabledThemes } = useQuery({
|
||||||
queryKey: ['enabled-themes'],
|
queryKey: ['enabled-themes'],
|
||||||
@@ -328,6 +335,30 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
{/* Animated Background */}
|
{/* Animated Background */}
|
||||||
<AnimatedBackground />
|
<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"
|
||||||
|
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' : ''}`}>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-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" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header
|
<header
|
||||||
className="sticky top-0 z-50 glass border-b border-dark-800/50"
|
className="sticky top-0 z-50 glass border-b border-dark-800/50"
|
||||||
|
|||||||
101
src/hooks/usePullToRefresh.ts
Normal file
101
src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface UsePullToRefreshOptions {
|
||||||
|
onRefresh?: () => void | Promise<void>
|
||||||
|
threshold?: number // How far to pull before triggering refresh (px)
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePullToRefresh({
|
||||||
|
onRefresh,
|
||||||
|
threshold = 80,
|
||||||
|
disabled = false,
|
||||||
|
}: UsePullToRefreshOptions = {}) {
|
||||||
|
const [isPulling, setIsPulling] = useState(false)
|
||||||
|
const [pullDistance, setPullDistance] = useState(0)
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
|
||||||
|
const startY = useRef(0)
|
||||||
|
const currentY = useRef(0)
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
setIsRefreshing(true)
|
||||||
|
try {
|
||||||
|
await onRefresh()
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: reload the page
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}, [onRefresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
// Only trigger if at top of page
|
||||||
|
if (window.scrollY > 5) return
|
||||||
|
|
||||||
|
startY.current = e.touches[0].clientY
|
||||||
|
currentY.current = e.touches[0].clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (startY.current === 0) return
|
||||||
|
if (window.scrollY > 5) {
|
||||||
|
// User scrolled down, reset
|
||||||
|
startY.current = 0
|
||||||
|
setPullDistance(0)
|
||||||
|
setIsPulling(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY.current = e.touches[0].clientY
|
||||||
|
const diff = currentY.current - startY.current
|
||||||
|
|
||||||
|
// Only track downward pulls
|
||||||
|
if (diff > 0) {
|
||||||
|
// Apply resistance - pull distance is less than actual finger movement
|
||||||
|
const resistance = 0.4
|
||||||
|
const distance = Math.min(diff * resistance, threshold * 1.5)
|
||||||
|
setPullDistance(distance)
|
||||||
|
setIsPulling(true)
|
||||||
|
|
||||||
|
// Prevent default scroll if we're pulling
|
||||||
|
if (distance > 10) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (pullDistance >= threshold && !isRefreshing) {
|
||||||
|
handleRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
startY.current = 0
|
||||||
|
setPullDistance(0)
|
||||||
|
setIsPulling(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
|
}
|
||||||
|
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPulling,
|
||||||
|
pullDistance,
|
||||||
|
isRefreshing,
|
||||||
|
progress: Math.min(pullDistance / threshold, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user