refactor: replace loading overlays with skeleton components for improved user experience across various pages

This commit is contained in:
Usman Baig
2026-02-22 18:01:45 +01:00
parent 574462a275
commit c100277955
19 changed files with 567 additions and 62 deletions

View File

@@ -15,7 +15,8 @@ import {
} from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { Button, ArrowLeftIcon, Spinner } from '@ciphera-net/ui'
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
import { NotificationsListSkeleton } from '@/components/skeletons'
import { toast } from '@ciphera-net/ui'
const PAGE_SIZE = 50
@@ -128,9 +129,7 @@ export default function NotificationsPage() {
</p>
{loading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
<NotificationsListSkeleton />
) : error ? (
<div className="p-6 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
{error}

View File

@@ -1,5 +1,6 @@
import { Suspense } from 'react'
import OrganizationSettings from '@/components/settings/OrganizationSettings'
import { SettingsFormSkeleton } from '@/components/skeletons'
export const metadata = {
title: 'Organization Settings - Pulse',
@@ -10,7 +11,17 @@ export default function OrgSettingsPage() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div>
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}>
<Suspense fallback={
<div className="space-y-8">
<div>
<div className="h-8 w-56 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-80 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
}>
<OrganizationSettings />
</Suspense>
</div>

View File

@@ -1,10 +1,19 @@
import { Suspense } from 'react'
import PricingSection from '@/components/PricingSection'
import { PricingCardsSkeleton } from '@/components/skeletons'
export default function PricingPage() {
return (
<div className="min-h-screen pt-20">
<Suspense fallback={<div className="min-h-screen pt-20 flex items-center justify-center">Loading...</div>}>
<Suspense fallback={
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-16">
<div className="text-center mb-12">
<div className="h-10 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto" />
</div>
<PricingCardsSkeleton />
</div>
}>
<PricingSection />
</Suspense>
</div>

View File

@@ -13,6 +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 ExportModal from '@/components/dashboard/ExportModal'
// Helper to get date ranges
@@ -193,7 +194,7 @@ export default function PublicDashboardPage() {
}
if (loading && !data && !isPasswordProtected) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
return <DashboardSkeleton />
}
if (isPasswordProtected && !data) {

View File

@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
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, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { FunnelDetailSkeleton } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
@@ -92,7 +93,7 @@ export default function FunnelReportPage() {
}
if (loading && !funnel) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
return <FunnelDetailSkeleton />
}
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton } from '@/components/skeletons'
import Link from 'next/link'
export default function FunnelsPage() {
@@ -44,7 +45,7 @@ export default function FunnelsPage() {
}
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
return <FunnelsListSkeleton />
}
return (

View File

@@ -11,6 +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 ExportModal from '@/components/dashboard/ExportModal'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
@@ -216,7 +217,7 @@ export default function SiteDashboardPage() {
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
return <DashboardSkeleton />
}
if (!site) {

View File

@@ -6,7 +6,8 @@ import { getSite, type Site } from '@/lib/api/sites'
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
import { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
@@ -90,7 +91,7 @@ export default function RealtimePage() {
}
}
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Realtime" />
if (loading) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (
@@ -197,9 +198,7 @@ export default function RealtimePage() {
Select a visitor on the left to see their activity.
</div>
) : loadingEvents ? (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-neutral-900 dark:border-white"></div>
</div>
<SessionEventsSkeleton />
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (

View File

@@ -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 { LoadingOverlay } from '@ciphera-net/ui'
import { SettingsFormSkeleton, GoalsListSkeleton } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import { PasswordInput } from '@ciphera-net/ui'
@@ -318,7 +318,26 @@ export default function SiteSettingsPage() {
}
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</nav>
<div className="flex-1 bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
</div>
</div>
)
}
if (!site) {
@@ -970,7 +989,7 @@ export default function SiteSettingsPage() {
</p>
</div>
{goalsLoading ? (
<div className="py-8 text-center text-neutral-500 dark:text-neutral-400">Loading goals</div>
<GoalsListSkeleton />
) : (
<>
{canEdit && (

View File

@@ -20,7 +20,8 @@ import {
import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton } from '@/components/skeletons'
import {
AreaChart,
Area,
@@ -510,9 +511,7 @@ function MonitorCard({
{/* Response time chart */}
{loadingChecks ? (
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm">
Loading checks...
</div>
<ChecksSkeleton />
) : checks.length > 0 ? (
<>
<ResponseTimeChart checks={checks} />
@@ -704,7 +703,7 @@ export default function UptimePage() {
setShowEditModal(true)
}
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Uptime" />
if (loading) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []

View File

@@ -3,7 +3,8 @@
import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { formatNumber } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon, Button, Spinner } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui'
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
@@ -292,9 +293,8 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<TableSkeleton rows={10} cols={5} />
</div>
) : (
<>

View File

@@ -3,7 +3,8 @@
import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon, Spinner } from '@ciphera-net/ui'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
interface ContentStatsProps {
topPages: TopPage[]
@@ -173,9 +174,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((page, index) => (

View File

@@ -6,7 +6,8 @@ import * as Flags from 'country-flag-icons/react/3x2'
// @ts-ignore
import iso3166 from 'iso-3166-2'
import WorldMap from './WorldMap'
import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { SiTorproject } from 'react-icons/si'
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
@@ -288,9 +289,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => (

View File

@@ -5,6 +5,7 @@ import { motion } from 'framer-motion'
import { ChevronDownIcon } from '@ciphera-net/ui'
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
import { Select } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
interface Props {
stats: Stats
@@ -205,7 +206,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
style={{ overflow: 'hidden' }}
>
{loadingTable ? (
<div className="py-8 text-center text-neutral-500 text-sm">Loading</div>
<div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
) : rows.length === 0 ? (
<div className="py-6 text-center text-neutral-500 text-sm">
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.

View File

@@ -4,7 +4,8 @@ import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { MdMonitor } from 'react-icons/md'
import { Modal, GridIcon, Spinner } from '@ciphera-net/ui'
import { Modal, GridIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
interface TechSpecsProps {
@@ -189,9 +190,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((item, index) => (

View File

@@ -3,7 +3,8 @@
import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
interface TopReferrersProps {
@@ -134,9 +135,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (

View File

@@ -10,6 +10,7 @@ import { listNotifications, markNotificationRead, markAllNotificationsRead, type
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { SettingsIcon } from '@ciphera-net/ui'
import { SkeletonLine, SkeletonCircle } from '@/components/skeletons'
// * Bell icon (simple SVG, no extra deps)
function BellIcon({ className }: { className?: string }) {
@@ -153,8 +154,16 @@ export default function NotificationCenter() {
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
Loading
<div className="p-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (

View File

@@ -37,6 +37,7 @@ import {
LayoutDashboardIcon,
Spinner,
} from '@ciphera-net/ui'
import { MembersListSkeleton, InvoicesListSkeleton, AuditLogSkeleton, SettingsFormSkeleton, SkeletonCard } from '@/components/skeletons'
// * Bell icon for notifications tab
function BellIcon({ className }: { className?: string }) {
@@ -740,9 +741,7 @@ export default function OrganizationSettings() {
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Active Members</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingMembers ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
<MembersListSkeleton />
) : members.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No members found.</div>
) : (
@@ -821,8 +820,9 @@ export default function OrganizationSettings() {
</div>
{isLoadingSubscription ? (
<div className="flex items-center justify-center py-12">
<Spinner />
<div className="space-y-4">
<SkeletonCard className="h-32" />
<SkeletonCard className="h-20" />
</div>
) : !subscription ? (
<div className="p-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
@@ -1046,9 +1046,7 @@ export default function OrganizationSettings() {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
<InvoicesListSkeleton />
) : invoices.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
) : (
@@ -1117,9 +1115,7 @@ export default function OrganizationSettings() {
</div>
{isLoadingNotificationSettings ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
<SettingsFormSkeleton />
) : (
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Notification categories</h3>
@@ -1244,9 +1240,7 @@ export default function OrganizationSettings() {
{/* Table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{isLoadingAudit ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
<AuditLogSkeleton />
) : (auditEntries ?? []).length === 0 ? (
<div className="p-8 text-center text-neutral-500">No audit events found.</div>
) : (

461
components/skeletons.tsx Normal file
View File

@@ -0,0 +1,461 @@
/**
* Reusable skeleton loading primitives and composites for Pulse.
* All skeletons follow the design-system pattern:
* animate-pulse + bg-neutral-100 dark:bg-neutral-800 + rounded
*/
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
// ─── Primitives ──────────────────────────────────────────────
export function SkeletonLine({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded ${className}`} />
}
export function SkeletonCircle({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-full ${className}`} />
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-2xl ${className}`} />
}
// ─── List skeleton (icon + two text lines per row) ───────────
export function ListRowSkeleton() {
return (
<div className="flex items-center justify-between h-9 px-2 -mx-2">
<div className="flex items-center gap-3 flex-1">
<SkeletonLine className="h-5 w-5 rounded shrink-0" />
<SkeletonLine className="h-4 w-3/5" />
</div>
<SkeletonLine className="h-4 w-12" />
</div>
)
}
export function ListSkeleton({ rows = 7 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
)
}
// ─── Table skeleton (header row + data rows) ─────────────────
export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: number }) {
return (
<div className="space-y-2">
<div className={`grid gap-2 mb-2 px-2`} style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, i) => (
<SkeletonLine key={`th-${i}`} className="h-4" />
))}
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={`tr-${i}`} className="grid gap-2 h-9 px-2 -mx-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, j) => (
<SkeletonLine key={`td-${i}-${j}`} className="h-4" />
))}
</div>
))}
</div>
)
}
// ─── Widget panel skeleton (used inside dashboard grid) ──────
export function WidgetSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<SkeletonLine className="h-6 w-32" />
<div className="flex gap-1">
<SkeletonLine className="h-7 w-16 rounded-lg" />
<SkeletonLine className="h-7 w-16 rounded-lg" />
</div>
</div>
<div className="space-y-2 flex-1 min-h-[270px]">
<ListSkeleton rows={7} />
</div>
</div>
)
}
// ─── Stat card skeleton ──────────────────────────────────────
export function StatCardSkeleton() {
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<SkeletonLine className="h-4 w-20 mb-2" />
<SkeletonLine className="h-8 w-28" />
</div>
)
}
// ─── Chart area skeleton ─────────────────────────────────────
export function ChartSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-7 w-24" />
</div>
))}
</div>
<SkeletonLine className="h-8 w-32 rounded-lg" />
</div>
<SkeletonLine className="h-64 w-full rounded-xl" />
</div>
)
}
// ─── Full dashboard skeleton ─────────────────────────────────
export function DashboardSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-32" />
</div>
<SkeletonLine className="h-8 w-40 rounded-full" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-10 w-24 rounded-lg" />
<SkeletonLine className="h-10 w-36 rounded-lg" />
</div>
</div>
</div>
{/* Chart */}
<div className="mb-8">
<ChartSkeleton />
</div>
{/* Widget grid (2 cols) */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
{/* Campaigns table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<TableSkeleton rows={7} cols={5} />
</div>
</div>
</div>
)
}
// ─── Realtime page skeleton ──────────────────────────────────
export function RealtimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors list */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}
// ─── Uptime page skeleton ────────────────────────────────────
export function UptimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
{/* Overall status */}
<SkeletonCard className="h-20 mb-6" />
{/* Monitor cards */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonCircle className="w-3 h-3" />
<SkeletonLine className="h-5 w-32" />
<SkeletonLine className="h-4 w-48 hidden sm:block" />
</div>
<SkeletonLine className="h-4 w-28" />
</div>
<SkeletonLine className="h-8 w-full rounded-sm" />
</div>
))}
</div>
</div>
)
}
// ─── Checks / Response time skeleton ─────────────────────────
export function ChecksSkeleton() {
return (
<div className="space-y-4">
<SkeletonLine className="h-40 w-full rounded-xl" />
<div className="space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2">
<div className="flex items-center gap-2">
<SkeletonCircle className="w-2 h-2" />
<SkeletonLine className="h-3 w-32" />
</div>
<SkeletonLine className="h-3 w-16" />
</div>
))}
</div>
</div>
)
}
// ─── Funnels list skeleton ───────────────────────────────────
export function FunnelsListSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
<div>
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
</div>
<div className="grid gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<div className="flex items-center gap-2">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex items-center">
<SkeletonLine className="h-7 w-20 rounded-lg" />
{j < 2 && <SkeletonLine className="h-4 w-4 mx-2 rounded" />}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─── Funnel detail skeleton ──────────────────────────────────
export function FunnelDetailSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonCard className="h-80 mb-8" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-28" />
))}
</div>
</div>
)
}
// ─── Notifications list skeleton ─────────────────────────────
export function NotificationsListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-2">
<SkeletonLine className="h-4 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
<SkeletonLine className="h-3 w-16 shrink-0" />
</div>
))}
</div>
)
}
// ─── Settings form skeleton ──────────────────────────────────
export function SettingsFormSkeleton() {
return (
<div className="space-y-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-10 w-full rounded-lg" />
</div>
))}
<SkeletonLine className="h-10 w-28 rounded-lg" />
</div>
)
}
// ─── Goals list skeleton ─────────────────────────────────────
export function GoalsListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-3 w-20" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-10" />
<SkeletonLine className="h-4 w-12" />
</div>
</div>
))}
</div>
)
}
// ─── Pricing cards skeleton ──────────────────────────────────
export function PricingCardsSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-3 max-w-5xl mx-auto">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-96" />
))}
</div>
)
}
// ─── Organization settings skeleton (members, billing, etc) ─
export function MembersListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-1">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-3 w-48" />
</div>
<SkeletonLine className="h-6 w-16 rounded-full" />
</div>
))}
</div>
)
}
export function InvoicesListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-lg">
<div className="flex items-center gap-3">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-4 w-20" />
</div>
))}
</div>
)
}
export function AuditLogSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-4">
<SkeletonLine className="h-3 w-28" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-48 flex-1" />
</div>
))}
</div>
)
}