diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
index 59f6c1a1..fc16dbfa 100644
--- a/src/app/dashboard/loading.tsx
+++ b/src/app/dashboard/loading.tsx
@@ -1,35 +1,11 @@
-import { Skeleton } from "@/components/ui/skeleton";
+import { DashboardSkeleton } from "@/components/ui/LoadingSkeletons";
export default function Loading() {
return (
-
diff --git a/src/components/referral/ReferralLeaderboard.tsx b/src/components/referral/ReferralLeaderboard.tsx
index 04c04644..aff1d26d 100644
--- a/src/components/referral/ReferralLeaderboard.tsx
+++ b/src/components/referral/ReferralLeaderboard.tsx
@@ -11,6 +11,8 @@ import { referralService } from '@/lib/referralService';
import { LeaderboardEntry, ReferralTier } from '@/types/referral';
import { formatUnits } from 'viem';
+import { TableSkeleton } from '@/components/ui/LoadingSkeletons';
+
export interface ReferralLeaderboardProps {
limit?: number;
compact?: boolean;
@@ -83,16 +85,7 @@ export default function ReferralLeaderboard({
}
if (isLoading) {
- return (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- );
+ return
;
}
if (error) {
diff --git a/src/components/ui/LoadingSkeletons.tsx b/src/components/ui/LoadingSkeletons.tsx
new file mode 100644
index 00000000..2e8f268d
--- /dev/null
+++ b/src/components/ui/LoadingSkeletons.tsx
@@ -0,0 +1,384 @@
+import React from "react"
+import { Skeleton } from "@/components/ui/skeleton"
+import { cn } from "@/lib/utils"
+
+interface CardSkeletonProps {
+ viewMode?: "grid" | "list"
+ count?: number
+ className?: string
+}
+
+/**
+ * Reusable CardSkeleton that matches the layout of PropertyCard.
+ * Supports both grid and list view modes.
+ */
+export function CardSkeleton({ viewMode = "grid", count = 1, className }: CardSkeletonProps) {
+ const isListView = viewMode === "list"
+
+ return (
+
+ {Array.from({ length: count }).map((_, idx) => (
+
+ {/* Image Skeleton */}
+
+
+
+ {/* Top Left Badges */}
+
+
+
+
+
+ {/* Top Right ROI Badge */}
+
+
+
+
+
+ {/* Bottom Left Blockchain Badge */}
+
+
+
+
+
+ {/* Content Area Skeleton */}
+
+
+ {/* Property Type Icon + Label */}
+
+
+
+
+
+ {/* Title */}
+
+
+
+
+
+ {/* Location */}
+
+
+
+
+
+ {/* Details (Bedrooms, Bathrooms, Sqft) */}
+
+
+
+
+
+
+
+ {/* Token Info Row */}
+
+
+ {/* Price and CTA Row */}
+
+
+
+ ))}
+
+ )
+}
+
+interface TableSkeletonProps {
+ rows?: number
+ columns?: number
+ showHeader?: boolean
+ className?: string
+}
+
+/**
+ * Reusable TableSkeleton that matches next.js/shadcn tables.
+ * Ideal for transaction tables, logs, list layouts.
+ */
+export function TableSkeleton({
+ rows = 5,
+ columns = 5,
+ showHeader = true,
+ className,
+}: TableSkeletonProps) {
+ return (
+
+
+
+ {showHeader && (
+
+
+ {Array.from({ length: columns }).map((_, i) => (
+ |
+
+ |
+ ))}
+
+
+ )}
+
+ {Array.from({ length: rows }).map((_, rIdx) => (
+
+ {Array.from({ length: columns }).map((_, cIdx) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+
+ )
+}
+
+interface ProfileSkeletonProps {
+ className?: string
+}
+
+/**
+ * Reusable ProfileSkeleton that matches ProfileSettingsForm and KYC layouts.
+ */
+export function ProfileSkeleton({ className }: ProfileSkeletonProps) {
+ return (
+
+ {/* Header section */}
+
+
+
+
+
+ {/* Profile avatar upload mock */}
+
+
+ {/* Form fields */}
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+ {i === 2 && }
+
+ ))}
+
+ {/* Switch field (Product updates toggle) */}
+
+
+
+ {/* Form Action Buttons */}
+
+
+
+
+
+ )
+}
+
+interface DashboardSkeletonProps {
+ className?: string
+}
+
+/**
+ * Reusable DashboardSkeleton representing portfolio details, stats, charts, and activity.
+ */
+export function DashboardSkeleton({ className }: DashboardSkeletonProps) {
+ return (
+
+ {/* Header title & primary buttons */}
+
+
+ {/* Stats Cards Row */}
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Chart Section */}
+
+
+ {/* Transaction / Activity Table */}
+
+
+ )
+}
+
+interface PropertyDetailSkeletonProps {
+ className?: string
+}
+
+/**
+ * Reusable PropertyDetailSkeleton for single property details view.
+ */
+export function PropertyDetailSkeleton({ className }: PropertyDetailSkeletonProps) {
+ return (
+
+ {/* Back button + title */}
+
+
+
+
+
+ {/* Main content grid */}
+
+ {/* Left column - images + details */}
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/* Right column - purchase card */}
+
+
+
+ )
+}
+
diff --git a/src/components/ui/__tests__/LoadingSkeletons.test.tsx b/src/components/ui/__tests__/LoadingSkeletons.test.tsx
new file mode 100644
index 00000000..999caef4
--- /dev/null
+++ b/src/components/ui/__tests__/LoadingSkeletons.test.tsx
@@ -0,0 +1,82 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { CardSkeleton, TableSkeleton, ProfileSkeleton, DashboardSkeleton } from "../LoadingSkeletons";
+
+describe("LoadingSkeletons", () => {
+ describe("CardSkeleton", () => {
+ it("renders the correct number of card skeleton items", () => {
+ const { container } = render(
);
+ // In grid mode, the wrapper grid holds 3 card items.
+ // Each card will have a structure containing skeletons.
+ const cards = container.querySelectorAll(".shadow-md");
+ expect(cards.length).toBe(3);
+ });
+
+ it("renders in grid view mode by default", () => {
+ const { container } = render(
);
+ const gridContainer = container.firstChild;
+ expect(gridContainer).toHaveClass("grid-cols-1");
+ });
+
+ it("renders in list view mode when viewMode is 'list'", () => {
+ const { container } = render(
);
+ const listContainer = container.firstChild;
+ expect(listContainer).toHaveClass("flex-col");
+
+ const card = container.querySelector(".shadow-md");
+ expect(card).toHaveClass("md:flex-row");
+ });
+ });
+
+ describe("TableSkeleton", () => {
+ it("renders the specified number of rows and columns", () => {
+ const { container } = render(
);
+ const tableRows = container.querySelectorAll("tbody tr");
+ expect(tableRows.length).toBe(4);
+
+ const headerCells = container.querySelectorAll("thead th");
+ expect(headerCells.length).toBe(6);
+
+ const firstRowCells = tableRows[0].querySelectorAll("td");
+ expect(firstRowCells.length).toBe(6);
+ });
+
+ it("does not render header when showHeader is false", () => {
+ const { container } = render(
);
+ const thead = container.querySelector("thead");
+ expect(thead).toBeNull();
+ });
+ });
+
+ describe("ProfileSkeleton", () => {
+ it("renders profile skeleton sections and inputs", () => {
+ const { container } = render(
);
+
+ // Avatar placeholder
+ const avatar = container.querySelector(".rounded-full");
+ expect(avatar).toBeInTheDocument();
+
+ // Form items/inputs placeholders
+ const flexContainer = container.querySelector(".rounded-3xl");
+ expect(flexContainer).toBeInTheDocument();
+ });
+ });
+
+ describe("DashboardSkeleton", () => {
+ it("renders stats, charts, and activity components", () => {
+ const { container } = render(
);
+
+ // Stats cards (4 items in row)
+ const statsGrid = container.querySelector(".grid-cols-4");
+ expect(statsGrid).toBeInTheDocument();
+
+ // Large chart row
+ const chartGrid = container.querySelector(".lg\\:col-span-2");
+ expect(chartGrid).toBeInTheDocument();
+
+ // Transaction / activity table
+ const activityTable = container.querySelector("table");
+ expect(activityTable).toBeInTheDocument();
+ });
+ });
+});