From d571b6156ff37903fe5af0ed5f4d869caac067cf Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Feb 2026 18:38:35 +0100 Subject: [PATCH] refactor: integrate useMinimumLoading hook for enhanced loading state management across multiple pages --- app/notifications/page.tsx | 5 ++-- app/share/[id]/page.tsx | 6 ++-- app/sites/[id]/funnels/[funnelId]/page.tsx | 6 ++-- app/sites/[id]/funnels/page.tsx | 6 ++-- app/sites/[id]/page.tsx | 6 ++-- app/sites/[id]/realtime/page.tsx | 6 ++-- app/sites/[id]/settings/page.tsx | 6 ++-- app/sites/[id]/uptime/page.tsx | 6 ++-- components/skeletons.tsx | 2 ++ components/useMinimumLoading.ts | 34 ++++++++++++++++++++++ 10 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 components/useMinimumLoading.ts diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index fa76498..2b4fe85 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -16,7 +16,7 @@ import { import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications' import { Button, ArrowLeftIcon } from '@ciphera-net/ui' -import { NotificationsListSkeleton } from '@/components/skeletons' +import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons' import { toast } from '@ciphera-net/ui' const PAGE_SIZE = 50 @@ -30,6 +30,7 @@ export default function NotificationsPage() { const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) + const showSkeleton = useMinimumLoading(loading) const fetchPage = async (pageOffset: number, append: boolean) => { if (append) setLoadingMore(true) @@ -128,7 +129,7 @@ export default function NotificationsPage() {

- {loading ? ( + {showSkeleton ? ( ) : error ? (
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index a79272f..7a1b04f 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -13,7 +13,7 @@ import Locations from '@/components/dashboard/Locations' import TechSpecs from '@/components/dashboard/TechSpecs' import PerformanceStats from '@/components/dashboard/PerformanceStats' import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui' -import { DashboardSkeleton } from '@/components/skeletons' +import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' // Helper to get date ranges @@ -193,7 +193,9 @@ export default function PublicDashboardPage() { loadDashboard() } - if (loading && !data && !isPasswordProtected) { + const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected) + + if (showSkeleton) { return } diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 29446ab..7bcd8ea 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation' import { ApiError } from '@/lib/api/client' import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui' -import { FunnelDetailSkeleton } from '@/components/skeletons' +import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons' import Link from 'next/link' import { BarChart, @@ -92,7 +92,9 @@ export default function FunnelReportPage() { } } - if (loading && !funnel) { + const showSkeleton = useMinimumLoading(loading && !funnel) + + if (showSkeleton) { return } diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index 20b32c3..e1ab641 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels' import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui' -import { FunnelsListSkeleton } from '@/components/skeletons' +import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons' import Link from 'next/link' export default function FunnelsPage() { @@ -44,7 +44,9 @@ export default function FunnelsPage() { } } - if (loading) { + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) { return } diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index e24b7f8..274b7ec 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -11,7 +11,7 @@ import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { LoadingOverlay, Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' -import { DashboardSkeleton } from '@/components/skeletons' +import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' import ContentStats from '@/components/dashboard/ContentStats' import TopReferrers from '@/components/dashboard/TopReferrers' @@ -216,7 +216,9 @@ export default function SiteDashboardPage() { return () => clearInterval(interval) }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime]) - if (loading) { + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) { return } diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx index 63ba754..a8ed5ca 100644 --- a/app/sites/[id]/realtime/page.tsx +++ b/app/sites/[id]/realtime/page.tsx @@ -7,7 +7,7 @@ import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { UserIcon } from '@ciphera-net/ui' -import { RealtimeSkeleton, SessionEventsSkeleton } from '@/components/skeletons' +import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons' import { motion, AnimatePresence } from 'framer-motion' function formatTimeAgo(dateString: string) { @@ -91,7 +91,9 @@ export default function RealtimePage() { } } - if (loading) return + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) return if (!site) return
Site not found
return ( diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index d09377d..5b10014 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -6,7 +6,7 @@ import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoData import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' -import { SettingsFormSkeleton, GoalsListSkeleton } from '@/components/skeletons' +import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons' import VerificationModal from '@/components/sites/VerificationModal' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' @@ -317,7 +317,9 @@ export default function SiteSettingsPage() { setTimeout(() => setSnippetCopied(false), 2000) } - if (loading) { + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) { return (
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index af3f943..d746d93 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -21,7 +21,7 @@ import { toast } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { Button, Modal } from '@ciphera-net/ui' -import { UptimeSkeleton, ChecksSkeleton } from '@/components/skeletons' +import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons' import { AreaChart, Area, @@ -703,7 +703,9 @@ export default function UptimePage() { setShowEditModal(true) } - if (loading) return + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) return if (!site) return
Site not found
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] diff --git a/components/skeletons.tsx b/components/skeletons.tsx index b1872c6..bf33111 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -6,6 +6,8 @@ const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800' +export { useMinimumLoading } from './useMinimumLoading' + // ─── Primitives ────────────────────────────────────────────── export function SkeletonLine({ className = '' }: { className?: string }) { diff --git a/components/useMinimumLoading.ts b/components/useMinimumLoading.ts new file mode 100644 index 0000000..a5a86c3 --- /dev/null +++ b/components/useMinimumLoading.ts @@ -0,0 +1,34 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' + +/** + * Prevents skeleton flicker on fast loads by keeping it visible + * for at least `minMs` once it appears. + * + * @param loading - The raw loading state from data fetching + * @param minMs - Minimum milliseconds the skeleton stays visible (default 300) + * @returns Whether the skeleton should be shown + */ +export function useMinimumLoading(loading: boolean, minMs = 300): boolean { + const [show, setShow] = useState(loading) + const startRef = useRef(loading ? Date.now() : 0) + + useEffect(() => { + if (loading) { + startRef.current = Date.now() + setShow(true) + } else { + const elapsed = Date.now() - startRef.current + const remaining = minMs - elapsed + if (remaining > 0) { + const timer = setTimeout(() => setShow(false), remaining) + return () => clearTimeout(timer) + } else { + setShow(false) + } + } + }, [loading, minMs]) + + return show +}