From c100277955db6c2d458f6c81efd9cbb80890a6aa Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Feb 2026 18:01:45 +0100 Subject: [PATCH] refactor: replace loading overlays with skeleton components for improved user experience across various pages --- app/notifications/page.tsx | 7 +- app/org-settings/page.tsx | 13 +- app/pricing/page.tsx | 11 +- app/share/[id]/page.tsx | 3 +- app/sites/[id]/funnels/[funnelId]/page.tsx | 5 +- app/sites/[id]/funnels/page.tsx | 5 +- app/sites/[id]/page.tsx | 3 +- app/sites/[id]/realtime/page.tsx | 9 +- app/sites/[id]/settings/page.tsx | 25 +- app/sites/[id]/uptime/page.tsx | 9 +- components/dashboard/Campaigns.tsx | 8 +- components/dashboard/ContentStats.tsx | 8 +- components/dashboard/Locations.tsx | 8 +- components/dashboard/PerformanceStats.tsx | 3 +- components/dashboard/TechSpecs.tsx | 8 +- components/dashboard/TopReferrers.tsx | 8 +- .../notifications/NotificationCenter.tsx | 13 +- components/settings/OrganizationSettings.tsx | 22 +- components/skeletons.tsx | 461 ++++++++++++++++++ 19 files changed, 567 insertions(+), 62 deletions(-) create mode 100644 components/skeletons.tsx diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index 0635cf5..fa76498 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -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() {

{loading ? ( -
- -
+ ) : error ? (
{error} diff --git a/app/org-settings/page.tsx b/app/org-settings/page.tsx index 0ed5837..2d7fdab 100644 --- a/app/org-settings/page.tsx +++ b/app/org-settings/page.tsx @@ -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 (
- Loading...
}> + +
+
+
+
+
+ +
+
+ }>
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 6381381..6163ba1 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,10 +1,19 @@ import { Suspense } from 'react' import PricingSection from '@/components/PricingSection' +import { PricingCardsSkeleton } from '@/components/skeletons' export default function PricingPage() { return (
- Loading...
}> + +
+
+
+
+ +
+ }>
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 5ed4452..a79272f 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -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 + return } if (isPasswordProtected && !data) { diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 3695f52..29446ab 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -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 + return } if (loadError === 'not_found' || (!funnel && !stats && !loadError)) { diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index 8f78661..20b32c3 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -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 + return } return ( diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index eb5665d..e24b7f8 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -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 + return } if (!site) { diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx index 78cb713..63ba754 100644 --- a/app/sites/[id]/realtime/page.tsx +++ b/app/sites/[id]/realtime/page.tsx @@ -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 + if (loading) return if (!site) return
Site not found
return ( @@ -197,9 +198,7 @@ export default function RealtimePage() { Select a visitor on the left to see their activity.
) : loadingEvents ? ( -
-
-
+ ) : (
{sessionEvents.map((event, idx) => ( diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index fc30439..d09377d 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 { 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 + return ( +
+
+
+
+
+
+
+ +
+ +
+
+
+
+ ) } if (!site) { @@ -970,7 +989,7 @@ export default function SiteSettingsPage() {

{goalsLoading ? ( -
Loading goals…
+ ) : ( <> {canEdit && ( diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index cf4b001..af3f943 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -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 ? ( -
- Loading checks... -
+ ) : checks.length > 0 ? ( <> @@ -704,7 +703,7 @@ export default function UptimePage() { setShowEditModal(true) } - if (loading) return + if (loading) return if (!site) return
Site not found
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index bd0f6a3..f4b5f02 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -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) { >
{isLoadingFull ? ( -
- -

Loading...

+
+
) : ( <> diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index b7d65dd..49f3363 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -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, >
{isLoadingFull ? ( -
- -

Loading...

+
+
) : ( (fullData.length > 0 ? fullData : data).map((page, index) => ( diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 4d85d34..43b0194 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -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 = ' >
{isLoadingFull ? ( -
- -

Loading...

+
+
) : ( (fullData.length > 0 ? fullData : data as any[]).map((item, index) => ( diff --git a/components/dashboard/PerformanceStats.tsx b/components/dashboard/PerformanceStats.tsx index 6b9b184..58af809 100644 --- a/components/dashboard/PerformanceStats.tsx +++ b/components/dashboard/PerformanceStats.tsx @@ -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 ? ( -
Loading…
+
) : rows.length === 0 ? (
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled. diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 04f3274..9733c97 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -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 >
{isLoadingFull ? ( -
- -

Loading...

+
+
) : ( (fullData.length > 0 ? fullData : data).map((item, index) => ( diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 4349118..b4f0974 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -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 >
{isLoadingFull ? ( -
- -

Loading...

+
+
) : ( mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => ( diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index 19f9a4c..357751c 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -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() {
{loading && ( -
- Loading… +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))}
)} {error && ( diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 327c857..bef72b3 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -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() {

Active Members

{isLoadingMembers ? ( -
- -
+ ) : members.length === 0 ? (
No members found.
) : ( @@ -821,8 +820,9 @@ export default function OrganizationSettings() {
{isLoadingSubscription ? ( -
- +
+ +
) : !subscription ? (
@@ -1046,9 +1046,7 @@ export default function OrganizationSettings() {

Recent invoices

{isLoadingInvoices ? ( -
- -
+ ) : invoices.length === 0 ? (
No invoices found.
) : ( @@ -1117,9 +1115,7 @@ export default function OrganizationSettings() {
{isLoadingNotificationSettings ? ( -
- -
+ ) : (

Notification categories

@@ -1244,9 +1240,7 @@ export default function OrganizationSettings() { {/* Table */}
{isLoadingAudit ? ( -
- -
+ ) : (auditEntries ?? []).length === 0 ? (
No audit events found.
) : ( diff --git a/components/skeletons.tsx b/components/skeletons.tsx new file mode 100644 index 0000000..b1872c6 --- /dev/null +++ b/components/skeletons.tsx @@ -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
+} + +export function SkeletonCircle({ className = '' }: { className?: string }) { + return
+} + +export function SkeletonCard({ className = '' }: { className?: string }) { + return
+} + +// ─── List skeleton (icon + two text lines per row) ─────────── + +export function ListRowSkeleton() { + return ( +
+
+ + +
+ +
+ ) +} + +export function ListSkeleton({ rows = 7 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ) +} + +// ─── Table skeleton (header row + data rows) ───────────────── + +export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: number }) { + return ( +
+
+ {Array.from({ length: cols }).map((_, i) => ( + + ))} +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ {Array.from({ length: cols }).map((_, j) => ( + + ))} +
+ ))} +
+ ) +} + +// ─── Widget panel skeleton (used inside dashboard grid) ────── + +export function WidgetSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ +
+
+ ) +} + +// ─── Stat card skeleton ────────────────────────────────────── + +export function StatCardSkeleton() { + return ( +
+ + +
+ ) +} + +// ─── Chart area skeleton ───────────────────────────────────── + +export function ChartSkeleton() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ +
+ +
+ ) +} + +// ─── Full dashboard skeleton ───────────────────────────────── + +export function DashboardSkeleton() { + return ( +
+ {/* Header */} +
+
+
+
+ + +
+ +
+
+ + +
+
+
+ + {/* Chart */} +
+ +
+ + {/* Widget grid (2 cols) */} +
+ + +
+
+ + +
+ + {/* Campaigns table */} +
+
+ + +
+
+
+ ) +} + +// ─── Realtime page skeleton ────────────────────────────────── + +export function RealtimeSkeleton() { + return ( +
+
+ + +
+
+ {/* Visitors list */} +
+
+ +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ +
+ + + +
+
+ ))} +
+
+ {/* Session details */} +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+
+ ) +} + +// ─── Session events skeleton (for loading events panel) ────── + +export function SessionEventsSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) +} + +// ─── Uptime page skeleton ──────────────────────────────────── + +export function UptimeSkeleton() { + return ( +
+
+ + + +
+ {/* Overall status */} + + {/* Monitor cards */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+ + + +
+ +
+ +
+ ))} +
+
+ ) +} + +// ─── Checks / Response time skeleton ───────────────────────── + +export function ChecksSkeleton() { + return ( +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+ ) +} + +// ─── Funnels list skeleton ─────────────────────────────────── + +export function FunnelsListSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ + {j < 2 && } +
+ ))} +
+
+ ))} +
+
+
+ ) +} + +// ─── Funnel detail skeleton ────────────────────────────────── + +export function FunnelDetailSkeleton() { + return ( +
+
+ + + +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ) +} + +// ─── Notifications list skeleton ───────────────────────────── + +export function NotificationsListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) +} + +// ─── Settings form skeleton ────────────────────────────────── + +export function SettingsFormSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} + +
+ ) +} + +// ─── Goals list skeleton ───────────────────────────────────── + +export function GoalsListSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+ ) +} + +// ─── Pricing cards skeleton ────────────────────────────────── + +export function PricingCardsSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) +} + +// ─── Organization settings skeleton (members, billing, etc) ─ + +export function MembersListSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) +} + +export function InvoicesListSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ ) +} + +export function AuditLogSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+ ) +}