Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions frontend/SEO_METADATA_CONVENTION.md
Original file line number Diff line number Diff line change
@@ -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 <PropertiesClient />
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/<id> | grep -E 'og:|twitter:|canonical|<title>'
curl -s http://localhost:3000/rating-card/<token> | 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.
149 changes: 149 additions & 0 deletions frontend/STATE_HANDLING_CONVENTION.md
Original file line number Diff line number Diff line change
@@ -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` | `<LoadingState label>` / `<LoadingAnnouncer>` | Pulsing grey placeholders shaped like the real content | "This is coming" |
| `error` | `<ErrorState onRetry>` | Destructive border and background, alert icon, retry button | "This failed; here's how to try again" |
| `empty` | `<EmptyState>` | 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 `<MoneyValue>`:

```tsx
<MoneyValue
status={isLoading ? "loading" : error ? "error" : "ready"}
amount={earnings?.totalEarnings} // null/undefined stays unknown, never 0
format={formatNgn}
/>
```

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. `<ErrorState>` 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
<EmptyState
icon={Heart}
title="No saved properties yet"
description="Tap the heart icon on any listing to save it here."
action={{ label: "Browse properties", href: "/properties" }}
/>
```

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

`<LoadingState label>` 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 `<LoadingAnnouncer>` 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 → `<StatCardSkeleton>`
- List, ledger, or payment row → `<ListRowSkeleton>`
- Property listing → `<PropertyCardSkeleton>`
- Anything else → `<Skeleton className="...">` sized to the real content

## Example

```tsx
{isLoading ? (
<LoadingState label="Loading payout schedule" className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => <ListRowSkeleton key={i} />)}
</LoadingState>
) : error ? (
<ErrorState
title="Payout schedule is unavailable"
description={error}
onRetry={fetchData}
/>
) : periods.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No payouts scheduled"
description="Payouts appear here once a tenant pays rent on one of your properties."
action={{ label: "Set up payouts", href: "/dashboard/landlord/settings/payouts" }}
/>
) : (
<PayoutList periods={periods} />
)}
```
Loading
Loading