refactor: replace loading overlays with skeleton components for improved user experience across various pages
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 : []
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
461
components/skeletons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user