diff --git a/frontend/SEO_METADATA_CONVENTION.md b/frontend/SEO_METADATA_CONVENTION.md
new file mode 100644
index 000000000..a4d965df1
--- /dev/null
+++ b/frontend/SEO_METADATA_CONVENTION.md
@@ -0,0 +1,101 @@
+# Page Metadata Convention
+
+Helpers live in [`lib/seo.ts`](lib/seo.ts). Site-wide defaults — `metadataBase`,
+the `%s | Shelterflex` title template, default OpenGraph and Twitter cards —
+are set once in [`app/layout.tsx`](app/layout.tsx).
+
+## Configuration
+
+Set `NEXT_PUBLIC_SITE_URL` per environment (e.g. `https://shelterflex.com`). It
+is the canonical origin, and `metadataBase` resolves every relative OpenGraph
+and canonical URL against it. Without it the build falls back to
+`http://localhost:3000`, which is fine locally and wrong in production.
+
+## Metadata must be server-rendered
+
+Crawlers and link unfurlers read the initial HTML. Metadata set from a client
+component is invisible to both, and Next.js rejects a `metadata` export from a
+`"use client"` module outright.
+
+Where a route's UI is a client component, the route file stays a server
+component and delegates:
+
+```
+app/properties/page.tsx // server: exports metadata, renders
+app/properties/PropertiesClient.tsx // "use client": the actual UI
+```
+
+`/`, `/properties`, `/landlords`, `/wallet`, and `/properties/[id]` all follow
+this shape.
+
+## Public routes
+
+Use `buildPageMetadata` — it emits the canonical URL, OpenGraph, and Twitter
+card together, so they cannot drift apart:
+
+```tsx
+export const metadata: Metadata = buildPageMetadata({
+ title: "Browse Rental Properties in Nigeria",
+ description: "Search verified rentals across Lagos, Abuja, ...",
+ path: "/properties",
+});
+```
+
+Titles omit the site name — the root template appends `| Shelterflex`. Pass
+`title: { absolute: ... }` for the homepage, where the suffix would repeat.
+
+Canonical URLs are the bare route path. `/properties` encodes filters, sorting,
+and pagination in the query string, and each combination would otherwise look
+like a separate page with near-identical content.
+
+## Property detail pages
+
+[`app/properties/[id]/page.tsx`](app/properties/[id]/page.tsx) fetches the
+listing in `generateMetadata` and builds a per-listing title
+(`42 Admiralty Way, Lekki Phase 1, Lagos`), a description from the listing's own
+copy — truncated to the ~160 characters unfurlers and search results show — and
+an absolute OpenGraph image from the listing's first photo. With a photo the
+card is `summary_large_image`; without one it degrades to `summary` on the site
+icon.
+
+The same route emits `Residence` JSON-LD with address, bedroom and bathroom
+counts, and an `Offer` carrying the annual rent in NGN. This was judged
+worthwhile: rental listings are the content type search engines surface with
+rich results, the data is already fetched server-side for the metadata, and
+every field maps onto an existing field on the listing record — nothing is
+invented. It is emitted only when the fetch succeeds.
+
+## Private and token-based routes
+
+Use `privatePageMetadata(title)`, or spread `NO_INDEX` into an existing metadata
+object. Coverage is by route segment, via a `layout.tsx`, so new pages inside a
+private segment inherit the exclusion instead of needing to remember it:
+
+`/admin`, `/dashboard`, `/wallet`, `/messages`, `/onboarding`, `/pre-screen`,
+`/report`, `/staking`, `/tenant`, `/verify-otp`, `/forgot-password`, `/offline`,
+`/whistleblower/dashboard`, `/whistleblower/earnings`, `/rating-card/[token]`,
+and `/public/tenant-rating/[token]`.
+
+`NO_INDEX` sets `noarchive` and `nosnippet` alongside `noindex`, which matters
+most for the two token routes: the token is the only access control there, so a
+cached copy or a search snippet would outlive its revocation and expose a named
+tenant's payment history.
+
+[`app/robots.ts`](app/robots.ts) repeats the same list at the crawler level, so
+well-behaved crawlers do not fetch those URLs at all. It is deliberately an
+exclusion list, not a sitemap or robots overhaul.
+
+## Verifying
+
+```bash
+NEXT_PUBLIC_SITE_URL=https://shelterflex.example pnpm run build
+NEXT_PUBLIC_SITE_URL=https://shelterflex.example pnpm start
+
+curl -s http://localhost:3000/properties/ | grep -E 'og:|twitter:|canonical|'
+curl -s http://localhost:3000/rating-card/ | grep 'name="robots"'
+curl -s http://localhost:3000/robots.txt
+```
+
+Because the tags are in the server-rendered HTML, the same output is what the
+Facebook Sharing Debugger, the X Card Validator, and LinkedIn's Post Inspector
+will read once the site is publicly reachable.
diff --git a/frontend/STATE_HANDLING_CONVENTION.md b/frontend/STATE_HANDLING_CONVENTION.md
new file mode 100644
index 000000000..3286bed69
--- /dev/null
+++ b/frontend/STATE_HANDLING_CONVENTION.md
@@ -0,0 +1,149 @@
+# Loading, Empty, and Error State Convention
+
+Every asynchronous surface resolves to exactly one of four states. Each has one
+component, in [`components/ui/data-state.tsx`](components/ui/data-state.tsx).
+Use them rather than hand-rolling per screen — the inconsistency is what made
+working screens read as broken.
+
+| State | Component | Looks like | Says |
+| --- | --- | --- | --- |
+| `loading` | `` / `` | Pulsing grey placeholders shaped like the real content | "This is coming" |
+| `error` | `` | Destructive border and background, alert icon, retry button | "This failed; here's how to try again" |
+| `empty` | `` | Dashed border, muted icon, headline plus a next-step button | "There's nothing here yet; here's how to change that" |
+| `ready` | the surface's own markup | — | — |
+
+The three states must never be confusable. A skeleton where an empty state
+belongs tells a new user the app is broken; a blank region where an error
+belongs makes them wait for content that is not coming.
+
+## Rules
+
+### 1. Never render a monetary value from a fallback
+
+This is the rule that matters most, and the one that is enforced.
+`formatNgn(balance ?? 0)` renders "₦0" — indistinguishable from a real zero
+balance, and a figure a user may act on. Use ``:
+
+```tsx
+
+```
+
+It renders a skeleton while loading, an em dash (with an
+`Amount unavailable` label for screen readers) when the amount is unknown or the
+fetch failed, and the formatted figure only when given a real number. A genuine
+`0` from the server still renders as `0`.
+
+Derive unknown amounts as `null`, not `0`:
+
+```tsx
+// Wrong — an unreachable API reports a zero balance.
+const totalEarned = earnings?.totalEarnings || 0;
+
+// Right — an unreachable API reports nothing.
+const totalEarned = earnings ? earnings.totalEarnings : null;
+```
+
+[`lib/__tests__/no-money-fallbacks.test.ts`](lib/__tests__/no-money-fallbacks.test.ts)
+scans the whole source tree for money formatters called on a `?? 0` / `|| 0`
+fallback and fails the test run if one reappears.
+
+### 2. Error states retry, they do not ask for a page reload
+
+`window.location.reload()` throws away every other section on the page to
+recover one, and loses unsaved form state. `` requires `onRetry`
+for that reason. The same guard test fails the build if a reload-based retry
+returns outside the service worker and the offline fallback, where reloading
+genuinely is the action.
+
+Where the fetch lives in an effect with a cancel-on-unmount guard, a reload
+token is the least invasive way to get a real retry:
+
+```tsx
+const [reloadToken, setReloadToken] = useState(0);
+const retry = useCallback(() => setReloadToken((t) => t + 1), []);
+useEffect(() => { /* ...existing fetch... */ }, [deps, reloadToken]);
+```
+
+Where the fetch is already a callback, keep the mount path and the retry path
+separate — otherwise the React Compiler lint rule flags the synchronous
+`setState` the retry needs:
+
+```tsx
+const loadStats = useCallback(() => { getStats().then(...).finally(...) }, []);
+useEffect(() => { loadStats(); }, [loadStats]);
+
+const retryStats = useCallback(() => {
+ setStatsLoading(true);
+ setStatsError(null);
+ loadStats();
+}, [loadStats]);
+```
+
+### 3. Empty states point at the next action
+
+An empty list is usually a new user's first impression of the feature, so it
+carries the call to action that would fill it. `action` takes either a link or a
+callback:
+
+```tsx
+
+```
+
+Filtered-empty is a different state from genuinely-empty: when filters are
+active, offer "Clear filters" instead of the onboarding action.
+
+### 4. Loading is announced, and does not shift the layout
+
+`` renders a polite `role="status"` live region and marks
+the placeholder shapes `aria-hidden` — the shapes carry no information, and
+announcing them adds noise. `Skeleton` itself is `aria-hidden` by default.
+
+Use `` alone when the skeletons cannot be wrapped (direct grid
+children, table rows) so the layout is untouched.
+
+Placeholders must match the dimensions of what replaces them. `StatCardSkeleton`
+and `ListRowSkeleton` mirror the real stat card and list row for this reason;
+`PropertyCardSkeleton` does the same for listings. When a section renders
+nothing at all while loading and a block of cards afterwards, that is a layout
+shift — render the skeletons in the same grid instead.
+
+## Choosing a placeholder
+
+- Stat / KPI card → ``
+- List, ledger, or payment row → ``
+- Property listing → ``
+- Anything else → `` sized to the real content
+
+## Example
+
+```tsx
+{isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => )}
+
+) : error ? (
+
+) : periods.length === 0 ? (
+
+) : (
+
+)}
+```
diff --git a/frontend/app/HomeClient.tsx b/frontend/app/HomeClient.tsx
new file mode 100644
index 000000000..ef7252570
--- /dev/null
+++ b/frontend/app/HomeClient.tsx
@@ -0,0 +1,321 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Link from "next/link";
+import {
+ ArrowRight,
+ Check,
+ ChevronRight,
+ Home,
+ Shield,
+ Clock,
+ Wallet,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { homePageBenefits } from "@/lib/content/homepage";
+import { getHomePageStats, type HomePageStats } from "@/lib/publicStatsApi";
+
+const iconMap = {
+ Wallet,
+ Clock,
+ Shield,
+ Home,
+};
+
+export default function HomeClient() {
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ getHomePageStats()
+ .then(setStats)
+ .catch(() => setStats(null));
+ }, []);
+
+ const homePageStats = stats
+ ? [
+ { value: stats.happyTenants, label: "Happy Tenants" },
+ { value: stats.rentFinanced, label: "Rent Financed" },
+ { value: stats.partnerLandlords, label: "Partner Landlords" },
+ { value: stats.citiesCovered, label: "Cities Covered" },
+ ]
+ : [];
+
+ return (
+
+ {/* Hero Section */}
+
+
+
+
+
+ NEW
+
+ Now available in Lagos, Abuja & Port Harcourt
+
+
+
+
+ Rent Now,
+
+ Pay Later.
+
+
+
+ Stop stressing about annual rent payments. Shelterflex helps you
+ split your rent into affordable monthly installments.
+
{periods.map((period) => (
diff --git a/frontend/app/dashboard/layout.tsx b/frontend/app/dashboard/layout.tsx
index 10cb9cb2a..8d3a58532 100644
--- a/frontend/app/dashboard/layout.tsx
+++ b/frontend/app/dashboard/layout.tsx
@@ -1,5 +1,13 @@
+import type { Metadata } from "next";
import { AuthGuard } from "@/components/auth-guard";
import { DashboardA11yEnhancer } from "@/components/dashboard/DashboardA11yEnhancer";
+import { privatePageMetadata } from "@/lib/seo";
+
+/**
+ * Every dashboard route sits behind AuthGuard and shows one user's own lease,
+ * payments, or portfolio, so the whole segment is excluded from indexing.
+ */
+export const metadata: Metadata = privatePageMetadata("Dashboard");
export default function DashboardLayout({
children,
diff --git a/frontend/app/dashboard/tenant/page.tsx b/frontend/app/dashboard/tenant/page.tsx
index 2823eb7c9..6ab9ba796 100644
--- a/frontend/app/dashboard/tenant/page.tsx
+++ b/frontend/app/dashboard/tenant/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import {
Building2,
@@ -13,12 +13,23 @@ import {
ArrowRight,
MapPin,
ShieldCheck,
- Loader2,
+ Heart,
+ Receipt,
} from "lucide-react";
import { PropertyCard } from "@/components/property-card";
+import { PropertyCardSkeleton } from "@/components/property-card-skeleton";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ EmptyState,
+ ErrorState,
+ ListRowSkeleton,
+ LoadingState,
+ MoneyValue,
+ StatCardSkeleton,
+} from "@/components/ui/data-state";
import { DashboardHeader } from "@/components/dashboard-header";
import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar";
import { TenantRewardsSummaryCard } from "@/components/tenant-rewards-summary-card";
@@ -79,7 +90,7 @@ export default function TenantDashboard() {
.catch(() => {});
}, []);
- useEffect(() => {
+ const loadLease = useCallback(() => {
getTenantCurrentLease()
.then((res) => {
setCurrentLease(res.data);
@@ -94,7 +105,7 @@ export default function TenantDashboard() {
.finally(() => setLeaseLoading(false));
}, []);
- useEffect(() => {
+ const loadPayments = useCallback(() => {
Promise.all([getPaymentSchedule(), getPaymentHistory({ limit: 10 })])
.then(([scheduleRes, historyRes]) => {
setPaymentSchedule(scheduleRes.data.schedule || []);
@@ -110,7 +121,7 @@ export default function TenantDashboard() {
.finally(() => setPaymentsLoading(false));
}, []);
- useEffect(() => {
+ const loadSaved = useCallback(() => {
fetchSavedListingIds()
.then((ids) => {
if (ids.length === 0) {
@@ -132,6 +143,38 @@ export default function TenantDashboard() {
.finally(() => setSavedLoading(false));
}, []);
+ useEffect(() => {
+ loadLease();
+ }, [loadLease]);
+
+ useEffect(() => {
+ loadPayments();
+ }, [loadPayments]);
+
+ useEffect(() => {
+ loadSaved();
+ }, [loadSaved]);
+
+ // Retry handlers reset to the loading state before re-fetching. Kept separate
+ // from the loaders above so the mount effects never call setState synchronously.
+ const retryLease = useCallback(() => {
+ setLeaseLoading(true);
+ setLeaseError(null);
+ loadLease();
+ }, [loadLease]);
+
+ const retryPayments = useCallback(() => {
+ setPaymentsLoading(true);
+ setPaymentsError(null);
+ loadPayments();
+ }, [loadPayments]);
+
+ const retrySaved = useCallback(() => {
+ setSavedLoading(true);
+ setSavedError(null);
+ loadSaved();
+ }, [loadSaved]);
+
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-NG", {
style: "currency",
@@ -163,6 +206,15 @@ export default function TenantDashboard() {
: 0;
const showOnboardingBanner = onboardingStatus && !onboardingStatus.submitted;
+ // Left null rather than 0 when either side is missing, so MoneyValue renders
+ // an explicit dash instead of a balance the server never sent.
+ const remainingBalance =
+ currentLease &&
+ Number.isFinite(currentLease.totalOwed) &&
+ Number.isFinite(currentLease.totalPaid)
+ ? currentLease.totalOwed - currentLease.totalPaid
+ : null;
+
const allPayments: PaymentItem[] = [
...pastPayments.map((p) => ({
...p,
@@ -237,16 +289,26 @@ export default function TenantDashboard() {
Welcome back, Ngozi!
{leaseLoading ? (
-
-
-
- Loading lease info...
-
-
+
+
+
+ ) : leaseError ? (
+
+ We couldn't load your lease just now.
+
) : currentLease ? (
Your next payment of{" "}
- {formatCurrency(currentLease.monthlyPayment)} is due on{" "}
+ {" "}
+ is due on{" "}
{new Date(currentLease.nextPaymentDate).toLocaleDateString()}
-
-
- );
-}
+/**
+ * Server entry point for `/properties` — see the note in `app/page.tsx` on why
+ * the route file is not the client component.
+ *
+ * The canonical URL is the bare `/properties` path deliberately: the browsing
+ * UI encodes every filter and page in the query string, and each combination
+ * would otherwise look like a separate page with near-identical content.
+ */
+export const metadata: Metadata = buildPageMetadata({
+ title: "Browse Rental Properties in Nigeria",
+ description:
+ "Search verified rentals across Lagos, Abuja, Port Harcourt, Ibadan, and Enugu. Filter by price, bedrooms, and location, and split the rent into monthly installments.",
+ path: "/properties",
+});
export default function PropertiesPage() {
- return (
-
-
- Loading properties...
-
-
- }
- >
-
-
- );
+ return ;
}
diff --git a/frontend/app/properties/saved/page.tsx b/frontend/app/properties/saved/page.tsx
index ce5f267cf..75304df4a 100644
--- a/frontend/app/properties/saved/page.tsx
+++ b/frontend/app/properties/saved/page.tsx
@@ -2,9 +2,13 @@
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
-import { Heart, ArrowLeft, Search, AlertCircle } from "lucide-react";
+import { Heart, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
+import {
+ EmptyState,
+ ErrorState,
+ LoadingState,
+} from "@/components/ui/data-state";
import { DashboardHeader } from "@/components/dashboard-header";
import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar";
import { PropertyCard } from "@/components/property-card";
@@ -27,6 +31,10 @@ export default function SavedPropertiesPage() {
const [properties, setProperties] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ // Bumping this re-runs the effect below, which keeps the existing
+ // cancel-on-unmount guard while giving the error state a real retry.
+ const [reloadToken, setReloadToken] = useState(0);
+ const retry = useCallback(() => setReloadToken((token) => token + 1), []);
useEffect(() => {
if (!isAuthenticated) {
@@ -78,7 +86,7 @@ export default function SavedPropertiesPage() {
return () => {
cancelled = true;
};
- }, [isAuthenticated]);
+ }, [isAuthenticated, reloadToken]);
const handleUnsave = useCallback(
async (listingId: string) => {
@@ -137,7 +145,7 @@ export default function SavedPropertiesPage() {
diff --git a/frontend/components/staking/StakingPage.tsx b/frontend/components/staking/StakingPage.tsx
index fef627adb..07eb50ba7 100644
--- a/frontend/components/staking/StakingPage.tsx
+++ b/frontend/components/staking/StakingPage.tsx
@@ -127,7 +127,13 @@ export default function StakingPage() {
if (stakingMode === "ngn_balance") {
if (!ngnBalance || amount > ngnBalance.availableNgn) {
- setStatus(`Insufficient NGN balance. Available: ₦${ngnBalance?.availableNgn.toLocaleString() || 0}`);
+ // Only quote a figure we actually hold; an unloaded balance says so
+ // rather than claiming the wallet has ₦0 available.
+ setStatus(
+ ngnBalance
+ ? `Insufficient NGN balance. Available: ₦${ngnBalance.availableNgn.toLocaleString()}`
+ : "We couldn't read your NGN balance. Please retry in a moment.",
+ );
return;
}
}
diff --git a/frontend/components/ui/data-state.test.tsx b/frontend/components/ui/data-state.test.tsx
new file mode 100644
index 000000000..ad89f0b11
--- /dev/null
+++ b/frontend/components/ui/data-state.test.tsx
@@ -0,0 +1,176 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import {
+ EmptyState,
+ ErrorState,
+ LoadingAnnouncer,
+ LoadingState,
+ MoneyValue,
+} from "./data-state";
+
+const formatNgn = (amount: number) =>
+ new Intl.NumberFormat("en-NG", {
+ style: "currency",
+ currency: "NGN",
+ minimumFractionDigits: 0,
+ }).format(amount);
+
+describe("MoneyValue", () => {
+ it("renders a skeleton and no digits while loading", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('[data-slot="skeleton"]')).not.toBeNull();
+ // The critical guarantee: nothing numeric reaches the DOM mid-fetch.
+ expect(container.textContent).not.toMatch(/\d/);
+ });
+
+ it("announces the in-flight fetch to assistive technology", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Loading balance");
+ });
+
+ it("renders an explicit dash rather than a number on error", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).toContain("—");
+ expect(container.textContent).not.toMatch(/\d/);
+ expect(screen.getByText("Balance unavailable")).toBeInTheDocument();
+ });
+
+ it("does not treat a missing amount as zero", () => {
+ for (const amount of [null, undefined, Number.NaN]) {
+ const { container, unmount } = render(
+ ,
+ );
+ expect(container.textContent).toContain("—");
+ expect(container.textContent).not.toMatch(/0/);
+ unmount();
+ }
+ });
+
+ it("formats a real amount once it has arrived", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).toContain("4,500,000");
+ });
+
+ it("still renders a genuine zero it was given", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).toContain("0");
+ expect(container.textContent).not.toContain("—");
+ });
+});
+
+describe("LoadingState", () => {
+ it("announces the fetch and hides the placeholder shapes", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const status = screen.getByRole("status");
+ expect(status).toHaveTextContent("Loading payment history");
+ expect(status).toHaveAttribute("aria-live", "polite");
+ expect(
+ container.querySelector('[data-slot="loading-state"]'),
+ ).toHaveAttribute("aria-hidden", "true");
+ });
+});
+
+describe("LoadingAnnouncer", () => {
+ it("renders a polite live region with no visual box", () => {
+ render();
+ const status = screen.getByRole("status");
+ expect(status).toHaveTextContent("Loading stats");
+ expect(status.className).toContain("sr-only");
+ });
+});
+
+describe("ErrorState", () => {
+ it("is exposed as an alert and offers a working retry", async () => {
+ const onRetry = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Failed to load payouts");
+ expect(screen.getByText("Network request failed")).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: /try again/i }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it("never tells the user to reload the page", () => {
+ render( {}} />);
+ expect(screen.getByRole("alert").textContent).not.toMatch(
+ /reload|refresh the page/i,
+ );
+ });
+});
+
+describe("EmptyState", () => {
+ it("guides the user toward the action that would populate it", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("No saved properties yet")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Browse properties" })).toHaveAttribute(
+ "href",
+ "/properties",
+ );
+ });
+
+ it("supports a callback action for in-page next steps", async () => {
+ const onClick = vi.fn();
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("is not announced as an alert, so it stays distinct from an error", () => {
+ render();
+ expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+});
diff --git a/frontend/components/ui/data-state.tsx b/frontend/components/ui/data-state.tsx
new file mode 100644
index 000000000..d5714fd3a
--- /dev/null
+++ b/frontend/components/ui/data-state.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import type { ComponentProps, ReactNode } from "react";
+import Link from "next/link";
+import { AlertTriangle, RefreshCw, type LucideIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * Shared loading / empty / error primitives.
+ *
+ * Every asynchronous surface in the app resolves to exactly one of four states,
+ * and each state has one component here. See STATE_HANDLING_CONVENTION.md for
+ * the decision table and migration notes.
+ *
+ * loading -> (or + bare skeletons)
+ * error ->
+ * empty -> with an action that would populate it
+ * ready -> the surface's own markup
+ *
+ * Monetary values are the one case where "render something plausible" is a bug
+ * rather than a nicety, so they get a dedicated component: .
+ */
+
+/** The four states any fetched surface can be in. */
+export type DataStatus = "loading" | "error" | "empty" | "ready";
+
+/* -------------------------------------------------------------------------- */
+/* Loading */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Screen-reader announcement for an in-flight fetch, with no visual box of its
+ * own. Use when the skeletons cannot be wrapped — direct children of a grid,
+ * table rows, and so on — so the layout stays untouched.
+ */
+export function LoadingAnnouncer({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+/**
+ * Wraps a block of skeletons: announces the fetch to assistive technology and
+ * hides the placeholder shapes from it, since reading them adds nothing.
+ *
+ * `className` is applied to the visual wrapper, so a caller replacing a grid of
+ * skeletons can move the grid classes here and keep the same layout.
+ */
+export function LoadingState({
+ label,
+ className,
+ children,
+ ...props
+}: ComponentProps<"div"> & { label: string }) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
+
+/**
+ * Placeholder for a stat / KPI card. Mirrors the dimensions of the real card so
+ * nothing jumps when the value arrives.
+ */
+export function StatCardSkeleton({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Placeholder for one row of a list or ledger. */
+export function ListRowSkeleton({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Error */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * A failed fetch. `onRetry` is required: an error state that tells the user to
+ * reload the page throws away everything else on screen to recover one section.
+ */
+export function ErrorState({
+ title = "Something went wrong",
+ description,
+ onRetry,
+ retryLabel = "Try again",
+ className,
+}: {
+ title?: string;
+ description?: ReactNode;
+ onRetry: () => void;
+ retryLabel?: string;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Empty */
+/* -------------------------------------------------------------------------- */
+
+export type EmptyStateAction =
+ | { label: string; href: string; onClick?: never }
+ | { label: string; onClick: () => void; href?: never };
+
+/**
+ * A successful fetch that returned nothing. Distinct from loading (no pulse)
+ * and from error (no destructive colouring), and carries the action that would
+ * populate it — for most surfaces this is a new user's first impression.
+ */
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+ className,
+}: {
+ icon?: LucideIcon;
+ title: string;
+ description: string;
+ /** The next step that would fill this surface. Omit only if there isn't one. */
+ action?: EmptyStateAction;
+ className?: string;
+}) {
+ const actionClassName =
+ "border-3 border-foreground bg-primary font-bold text-primary-foreground shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[1px_1px_0px_0px_rgba(26,26,26,1)]";
+
+ return (
+