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 ( -
- {/* Header */} -
- - +
+
+
- - {/* Portfolio overview */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - -
- ))} -
- - {/* Charts row */} -
- - -
- - {/* Transactions */} -
); } diff --git a/src/app/dashboard/saved-searches/page.tsx b/src/app/dashboard/saved-searches/page.tsx index b7bf505c..f293f33b 100644 --- a/src/app/dashboard/saved-searches/page.tsx +++ b/src/app/dashboard/saved-searches/page.tsx @@ -27,6 +27,7 @@ import { import { Input } from "@/components/ui/input"; import { Search, Filter, Settings, Bell, Mail, Bookmark } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { CardSkeleton } from "@/components/ui/LoadingSkeletons"; function SavedSearchesContent() { const { address } = useWalletStore(); @@ -274,24 +275,7 @@ function SavedSearchesContent() { )} {/* Loading State */} - {isLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - - - - - - - - - - - - ))} -
- )} + {isLoading && } {!isLoading && filteredAndSortedSearches.length === 0 && ( diff --git a/src/app/properties/[id]/loading.tsx b/src/app/properties/[id]/loading.tsx index d0d73241..6012b7c4 100644 --- a/src/app/properties/[id]/loading.tsx +++ b/src/app/properties/[id]/loading.tsx @@ -1,29 +1,10 @@ -import { Skeleton } from "@/components/ui/skeleton"; +import { PropertyDetailSkeleton } from "@/components/ui/LoadingSkeletons"; export default function Loading() { return (
-
- {/* Back button + title */} -
- - -
- - {/* Main content grid */} -
- {/* Left column - images + details */} -
- - -
- - {/* Right column - purchase card */} -
- - -
-
+
+
); diff --git a/src/app/secondary-market/page.tsx b/src/app/secondary-market/page.tsx index bf824358..33bb82b5 100644 --- a/src/app/secondary-market/page.tsx +++ b/src/app/secondary-market/page.tsx @@ -6,6 +6,7 @@ import { SecondaryMarketListing } from '@/types/property'; import { WalletConnector } from '@/components/WalletConnector'; import { Button } from '@/components/ui/button'; import { LoadingSpinner } from '@/components/LoadingSpinner'; +import { CardSkeleton } from '@/components/ui/LoadingSkeletons'; import Link from 'next/link'; import Image from 'next/image'; import { toast } from 'sonner'; @@ -72,10 +73,7 @@ export default function SecondaryMarketPage() {
{isLoading ? ( -
- -

Loading market listings...

-
+ ) : listings.length === 0 ? (

No active listings in the secondary market yet.

diff --git a/src/components/PropertyPageSkeleton.tsx b/src/components/PropertyPageSkeleton.tsx index 02e45968..3df5018c 100644 --- a/src/components/PropertyPageSkeleton.tsx +++ b/src/components/PropertyPageSkeleton.tsx @@ -1,4 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; +import { CardSkeleton } from "@/components/ui/LoadingSkeletons"; /** * Skeleton fallback for the properties listing page. @@ -33,24 +34,7 @@ export default function PropertyPageSkeleton() {
{/* Property card grid skeleton */} -
- {Array.from({ length: 9 }).map((_, i) => ( -
- -
- - -
- - -
-
-
- ))} -
+
); diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 37c55bbc..10cba465 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { EmptyState } from '@/components/ui/EmptyState'; import { History } from 'lucide-react'; +import { TableSkeleton } from '@/components/ui/LoadingSkeletons'; const TRANSACTION_TYPES: TransactionType[] = ['purchase', 'transfer', 'management', 'other']; const TRANSACTION_STATUSES: TransactionStatus[] = ['pending', 'processing', 'confirmed', 'failed', 'cancelled']; @@ -289,68 +290,61 @@ export const TransactionHistory: React.FC = () => { )} {/* Transaction Table */} -
- - - - Hash - Type - Status - From - To - Time - - - - {isLoading ? ( - Array.from({ length: 8 }).map((_, i) => ( - - - - - - - - - )) - ) : rowsToRender.length === 0 ? ( + {isLoading ? ( + + ) : ( +
+
+ - - - + Hash + Type + Status + From + To + Time - ) : ( - rowsToRender.map((tx) => ( - - - {tx.hash.slice(0, 10)}…{tx.hash.slice(-8)} - - {tx.type} - {tx.status} - - {tx.from.slice(0, 10)}…{tx.from.slice(-8)} - - - {tx.to ? `${tx.to.slice(0, 10)}…${tx.to.slice(-8)}` : '-'} - - - {new Date(tx.timestamp).toLocaleString()} + + + {rowsToRender.length === 0 ? ( + + + - )) - )} - -
-
+ ) : ( + rowsToRender.map((tx) => ( + + + {tx.hash.slice(0, 10)}…{tx.hash.slice(-8)} + + {tx.type} + {tx.status} + + {tx.from.slice(0, 10)}…{tx.from.slice(-8)} + + + {tx.to ? `${tx.to.slice(0, 10)}…${tx.to.slice(-8)}` : '-'} + + + {new Date(tx.timestamp).toLocaleString()} + + + )) + )} + + + + )} {!isLoading && filteredTransactions.length > 0 && (
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(); + }); + }); +});