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/<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. 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` | `<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} /> +)} +``` 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<HomePageStats | null>(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 ( + <main> + {/* Hero Section */} + <section className="relative overflow-hidden bg-background py-12 sm:py-16 md:py-20 lg:py-24"> + <div className="container mx-auto px-4 sm:px-6"> + <div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center"> + <div className="space-y-6 sm:space-y-8"> + <div className="inline-flex items-center gap-2 border-3 border-foreground bg-accent px-3 py-1.5 sm:px-4 sm:py-2 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + <span className="font-mono text-xs sm:text-sm font-bold">NEW</span> + <span className="text-xs sm:text-sm"> + Now available in Lagos, Abuja & Port Harcourt + </span> + </div> + + <h1 className="font-mono text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black leading-tight text-balance"> + Rent Now, + <br /> + <span className="text-primary">Pay Later.</span> + </h1> + + <p className="text-base sm:text-lg md:text-xl max-w-lg leading-relaxed text-muted-foreground"> + Stop stressing about annual rent payments. Shelterflex helps you + split your rent into affordable monthly installments. + </p> + + <div className="flex flex-col gap-3 sm:flex-row sm:gap-4"> + <Link href="/properties"> + <Button className="border-3 border-foreground bg-primary px-6 py-4 sm:px-8 sm:py-6 text-base sm:text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] min-h-12 w-full sm:w-auto"> + Find a Home + <ArrowRight className="ml-2 h-4 w-4 sm:h-5 sm:w-5" /> + </Button> + </Link> + <Link href="/calculator"> + <Button + variant="outline" + className="border-3 border-foreground bg-background px-6 py-4 sm:px-8 sm:py-6 text-base sm:text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] min-h-12 w-full sm:w-auto" + > + Calculate Payments + </Button> + </Link> + </div> + + <div className="flex items-center gap-3 pt-4 sm:pt-6"> + <div className="flex -space-x-2 sm:-space-x-3"> + {[1, 2, 3, 4].map((i) => ( + <div + key={i} + className="h-8 w-8 sm:h-10 sm:w-10 rounded-full border-3 border-foreground bg-secondary" + /> + ))} + </div> + <p className="text-xs sm:text-sm text-muted-foreground"> + <span className="font-bold text-foreground">2,400+</span>{" "} + tenants joined this month + </p> + </div> + </div> + + <div className="relative"> + <div className="border-3 border-foreground bg-card p-6 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]"> + <div className="mb-4 flex items-center justify-between"> + <span className="font-mono text-sm font-bold text-muted-foreground"> + PAYMENT PREVIEW + </span> + <span className="border-2 border-foreground bg-secondary px-2 py-1 text-xs font-bold"> + SAMPLE + </span> + </div> + <div className="space-y-4"> + <div className="border-b-2 border-dashed border-foreground/30 pb-4"> + <p className="text-sm text-muted-foreground">Annual Rent</p> + <p className="font-mono text-3xl font-black">₦2,400,000</p> + </div> + <div className="flex items-center gap-2 text-muted-foreground"> + <ChevronRight className="h-4 w-4" /> + <span>Split into 12 monthly payments</span> + </div> + <div className="border-3 border-foreground bg-primary/10 p-4"> + <p className="text-sm text-muted-foreground"> + You pay monthly + </p> + <p className="font-mono text-4xl font-black text-primary"> + ₦215,000 + </p> + <p className="text-xs text-muted-foreground mt-1"> + *excludes inspection fee + 20% deposit + </p> + </div> + </div> + </div> + + <div className="absolute -right-4 -top-4 h-16 w-16 border-3 border-foreground bg-accent" /> + <div className="absolute -bottom-4 -left-4 h-12 w-12 border-3 border-foreground bg-secondary" /> + </div> + </div> + </div> + </section> + + {/* Stats Bar */} + {homePageStats.length > 0 && ( + <section className="border-y-3 border-foreground bg-foreground py-6"> + <div className="container mx-auto px-4"> + <div className="grid grid-cols-2 gap-8 md:grid-cols-4"> + {homePageStats.map((stat) => ( + <div key={stat.label} className="text-center"> + <p className="font-mono text-2xl font-black text-background md:text-3xl"> + {stat.value} + </p> + <p className="text-sm text-background/70">{stat.label}</p> + </div> + ))} + </div> + </div> + </section> + )} + + {/* How It Works */} + <section className="bg-muted py-16 md:py-24"> + <div className="container mx-auto px-4"> + <div className="mb-12 text-center"> + <span className="mb-4 inline-block border-3 border-foreground bg-accent px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + HOW IT WORKS + </span> + <h2 className="font-mono text-3xl font-black md:text-5xl text-balance"> + Get Your Dream Home in 4 Simple Steps + </h2> + </div> + + <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> + {[ + { + step: "01", + title: "Browse Properties", + desc: "Explore verified rental listings in your preferred location.", + }, + { + step: "02", + title: "Apply Online", + desc: "Submit your application with basic documents in minutes.", + }, + { + step: "03", + title: "Get Approved", + desc: "Receive approval within 24 hours of application.", + }, + { + step: "04", + title: "Move In", + desc: "Pay your first installment and get your keys.", + }, + ].map((item, i) => { + let stepColorClass = "text-secondary"; + if (i % 2 === 0) stepColorClass = "text-primary"; + + return ( + <div + key={item.step} + className="group border-3 border-foreground bg-card p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + > + <span + className={`mb-4 inline-block font-mono text-5xl font-black ${stepColorClass}`} + > + {item.step} + </span> + <h3 className="mb-2 font-mono text-xl font-bold"> + {item.title} + </h3> + <p className="text-muted-foreground">{item.desc}</p> + </div> + ); + })} + </div> + </div> + </section> + + {/* Benefits Section */} + <section className="py-16 md:py-24"> + <div className="container mx-auto px-4"> + <div className="grid gap-12 lg:grid-cols-2 items-center"> + <div> + <span className="mb-4 inline-block border-3 border-foreground bg-secondary px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + WHY SHELTERFLEX + </span> + <h2 className="mb-6 font-mono text-3xl font-black md:text-5xl text-balance"> + Renting Made <span className="text-primary">Stress-Free</span> + </h2> + <p className="mb-8 text-lg text-muted-foreground leading-relaxed"> + We understand that coming up with a full year rent upfront is + challenging. That is why we created a solution that works for + everyone. + </p> + + <div className="space-y-4"> + {[ + "No collateral required", + "Flexible payment terms", + "Build your credit score", + "24/7 customer support", + ].map((item) => ( + <div key={item} className="flex items-center gap-3"> + <div className="flex h-8 w-8 items-center justify-center border-3 border-foreground bg-secondary"> + <Check className="h-4 w-4" /> + </div> + <span className="font-medium">{item}</span> + </div> + ))} + </div> + + <div className="mt-8"> + <Link href="/about"> + <Button className="border-3 border-foreground bg-primary px-6 py-4 font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> + Learn More About Us + <ArrowRight className="ml-2 h-4 w-4" /> + </Button> + </Link> + </div> + </div> + + <div className="grid gap-4 sm:grid-cols-2"> + {homePageBenefits.map((benefit, i) => { + const iconKey = Object.keys(iconMap)[ + i % 4 + ] as keyof typeof iconMap; + const Icon = iconMap[iconKey]; + let bgClass = "bg-card"; + if (i === 0) bgClass = "bg-primary/10"; + else if (i === 1) bgClass = "bg-secondary/30"; + else if (i === 2) bgClass = "bg-accent/30"; + return ( + <div + key={benefit.title} + className={`border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] ${bgClass}`} + > + <Icon className="mb-4 h-8 w-8" /> + <h3 className="mb-2 font-mono text-lg font-bold"> + {benefit.title} + </h3> + <p className="text-sm text-muted-foreground"> + {benefit.description} + </p> + </div> + ); + })} + </div> + </div> + </div> + </section> + + {/* CTA Section */} + <section className="border-y-3 border-foreground bg-primary py-16 md:py-24"> + <div className="container mx-auto px-4 text-center"> + <h2 className="mb-6 font-mono text-3xl font-black text-primary-foreground md:text-5xl text-balance"> + Ready to Find Your New Home? + </h2> + <p className="mb-8 text-lg text-primary-foreground/80 max-w-2xl mx-auto leading-relaxed"> + Join thousands of Nigerians who have made the smart choice. Start + your journey to stress-free renting today. + </p> + <div className="flex flex-wrap justify-center gap-4"> + <Link href="/signup"> + <Button className="border-3 border-foreground bg-background px-8 py-6 text-lg font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> + Get Started Free + <ArrowRight className="ml-2 h-5 w-5" /> + </Button> + </Link> + <Link href="/landlords"> + <Button + variant="outline" + className="border-3 border-foreground bg-transparent px-8 py-6 text-lg font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] hover:bg-background/10" + > + I am a Landlord + </Button> + </Link> + </div> + </div> + </section> + </main> + ); +} diff --git a/frontend/app/admin/analytics/AdminAnalyticsClient.tsx b/frontend/app/admin/analytics/AdminAnalyticsClient.tsx index d7712e906..3036e719e 100644 --- a/frontend/app/admin/analytics/AdminAnalyticsClient.tsx +++ b/frontend/app/admin/analytics/AdminAnalyticsClient.tsx @@ -12,6 +12,7 @@ import { Award, } from "lucide-react"; import { KPICard } from "@/components/admin/KPICard"; +import { ErrorState, MoneyValue } from "@/components/ui/data-state"; import dynamic from "next/dynamic"; import { getAnalyticsOverview, @@ -125,13 +126,26 @@ export function AdminAnalyticsClient() { loadData(true); }; - // Sum total users across roles + // Sum total users across roles. Left null when the overview never arrived so + // the KPI renders a dash rather than a figure the platform did not report. const totalUsers = overview ? overview.usersByRole.tenant + overview.usersByRole.landlord + overview.usersByRole.agent + overview.usersByRole.admin - : 0; + : null; + + const kpiStatus: "loading" | "error" | "ready" = loading + ? "loading" + : error || !overview + ? "error" + : "ready"; + + /** Non-monetary KPIs still refuse to invent a value; they just dash out. */ + const renderMetric = (value: number | null | undefined, suffix = "") => + value === null || value === undefined || !Number.isFinite(value) + ? "—" + : `${value.toLocaleString()}${suffix}`; // Format currency values const formatCurrency = (val: number) => { @@ -167,35 +181,45 @@ export function AdminAnalyticsClient() { {/* Error state */} {error && ( - <div className="border-3 border-red-600 bg-red-50 text-red-900 p-4 font-mono text-sm shadow-[4px_4px_0px_0px_rgba(220,38,38,1)] flex items-center gap-3"> - <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" /> - <span>{error}</span> - </div> + <ErrorState + title="Analytics are unavailable" + description={error} + onRetry={() => loadData(true)} + retryLabel="Retry" + /> )} {/* KPI Cards Grid */} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> <KPICard title="Total Platform Users" - value={loading ? "..." : totalUsers} + value={renderMetric(totalUsers)} change={12.4} changeLabel="vs last month" icon={<Users className="w-5 h-5 text-foreground" />} isLoading={loading} - sparklineData={[1200, 1310, 1290, 1380, 1490, 1550, 1690, totalUsers || 1792]} + sparklineData={[1200, 1310, 1290, 1380, 1490, 1550, 1690, totalUsers ?? 1792]} /> <KPICard title="Active Tenant Deals" - value={loading ? "..." : overview?.activeDeals || 0} + value={renderMetric(overview?.activeDeals)} change={8.2} changeLabel="vs last month" icon={<Activity className="w-5 h-5 text-foreground" />} isLoading={loading} - sparklineData={[25, 30, 28, 32, 38, 35, 40, overview?.activeDeals || 42]} + sparklineData={[25, 30, 28, 32, 38, 35, 40, overview?.activeDeals ?? 42]} /> <KPICard title="Revenue (MTD)" - value={loading ? "..." : formatCurrency(overview?.revenueMtd || 0)} + value={ + <MoneyValue + status={kpiStatus} + amount={overview?.revenueMtd} + format={formatCurrency} + skeletonClassName="h-8 w-40" + unavailableLabel="Revenue unavailable" + /> + } change={14.7} changeLabel="vs last month" icon={<TrendingUp className="w-5 h-5 text-foreground" />} @@ -204,7 +228,7 @@ export function AdminAnalyticsClient() { /> <KPICard title="Tenant Default Rate" - value={loading ? "..." : `${overview?.defaultRate || 0.0}%`} + value={renderMetric(overview?.defaultRate, "%")} change={-15.3} // default rate went down (good trend) changeLabel="vs last month" icon={<Percent className="w-5 h-5 text-foreground" />} @@ -240,7 +264,7 @@ export function AdminAnalyticsClient() { Inspection Pass Rate </span> <h4 className="font-mono text-2xl font-black mt-0.5"> - {loading ? "..." : `${quality?.inspectionPassRate || 92.5}%`} + {loading ? "…" : renderMetric(quality?.inspectionPassRate, "%")} </h4> </div> </div> @@ -260,7 +284,7 @@ export function AdminAnalyticsClient() { Avg Listing Quality </span> <h4 className="font-mono text-2xl font-black mt-0.5"> - {loading ? "..." : `${quality?.averageListingScore || 88.4}/100`} + {loading ? "…" : renderMetric(quality?.averageListingScore, "/100")} </h4> </div> </div> @@ -280,7 +304,7 @@ export function AdminAnalyticsClient() { Whistleblower Reports </span> <h4 className="font-mono text-2xl font-black mt-0.5"> - {loading ? "..." : `${quality?.whistleblowerReportRate || 4.2}%`} + {loading ? "…" : renderMetric(quality?.whistleblowerReportRate, "%")} </h4> </div> </div> diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 000000000..68300eac9 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Internal operator tooling. Excluded from indexing for the whole /admin segment. + */ +export const metadata: Metadata = privatePageMetadata("Admin"); + +export default function AdminSectionLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/calculator/rent-to-own/page.tsx b/frontend/app/calculator/rent-to-own/page.tsx index cbf62c52a..8380a7929 100644 --- a/frontend/app/calculator/rent-to-own/page.tsx +++ b/frontend/app/calculator/rent-to-own/page.tsx @@ -1,12 +1,14 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import RentToOwnCalculator from "@/components/calculator/RentToOwnCalculator"; +import { buildPageMetadata } from "@/lib/seo"; -export const metadata = { - title: "Rent-to-Own Calculator | Shelterflex", +export const metadata = buildPageMetadata({ + title: "Rent-to-Own Calculator", description: "Explore how the Shelterflex rent-to-own programme could help you build equity toward owning your home.", -}; + path: "/calculator/rent-to-own", +}); export default function RentToOwnPage() { return ( diff --git a/frontend/app/cookies/page.tsx b/frontend/app/cookies/page.tsx index 8f3b26b93..fb73b5be4 100644 --- a/frontend/app/cookies/page.tsx +++ b/frontend/app/cookies/page.tsx @@ -10,7 +10,7 @@ export const dynamic = "force-static"; * real one. */ export const metadata: Metadata = { - title: "Cookie Policy — Shelterflex", + title: "Cookie Policy", description: "Understand how Shelterflex uses cookies and similar technologies. Official legal copy will be updated before launch.", alternates: { canonical: "/cookies" }, diff --git a/frontend/app/dashboard/inspector/earnings/page.tsx b/frontend/app/dashboard/inspector/earnings/page.tsx index 441cfce08..c17accba9 100644 --- a/frontend/app/dashboard/inspector/earnings/page.tsx +++ b/frontend/app/dashboard/inspector/earnings/page.tsx @@ -1,19 +1,17 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import Link from "next/link"; -import { - DollarSign, - Building2, - CheckCircle, - Clock, - RefreshCw, - FileText, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { DollarSign, CheckCircle, Clock } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -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 { propertyInspectionApi, type InspectorEarnings } from "@/lib/propertyInspectionApi"; @@ -42,9 +40,20 @@ export default function EarningsPage() { fetchEarnings(); }, [fetchEarnings]); - const totalEarned = earnings?.totalEarnings || 0; - const paidAmount = totalEarned; // Assuming all approved inspections are paid - const pendingAmount = 0; + // Deliberately `null` rather than `0` when nothing has loaded: a failed fetch + // used to render "₦0 Total Earned", which is a plausible figure an inspector + // could act on. Unknown amounts render as a dash via MoneyValue instead. + const totalEarned = earnings ? earnings.totalEarnings : null; + const paidAmount = totalEarned; // Approved inspections are paid out in full. + const pendingAmount = earnings ? 0 : null; + + const moneyStatus: "loading" | "error" | "ready" = isLoading + ? "loading" + : error || !earnings + ? "error" + : "ready"; + + const formatNaira = (amount: number) => `₦${amount.toLocaleString()}`; if (!isEnabled) { return ( @@ -88,74 +97,74 @@ export default function EarningsPage() { {/* Error */} {error && !isLoading && ( - <Card className="mb-8 border-3 border-foreground p-6 text-center shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <p className="text-destructive">{error}</p> - <Button - onClick={fetchEarnings} - className="mt-4 border-3 border-foreground bg-primary shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - <RefreshCw className="mr-2 h-4 w-4" /> - Retry - </Button> - </Card> + <ErrorState + className="mb-8" + title="Failed to load earnings" + description={error} + onRetry={fetchEarnings} + retryLabel="Retry" + /> )} {/* Stats */} {isLoading ? ( - <div className="mb-8 grid gap-4 md:grid-cols-3"> + <LoadingState + label="Loading earnings totals" + className="mb-8 grid gap-4 md:grid-cols-3" + > {[1, 2, 3].map((i) => ( - <Skeleton key={i} className="h-32 border-3 border-foreground" /> + <StatCardSkeleton key={i} className="h-32" /> ))} - </div> + </LoadingState> ) : ( <div className="mb-8 grid gap-4 md:grid-cols-3"> - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-center justify-between"> - <div> - <p className="text-sm font-medium text-muted-foreground"> - Total Earned - </p> - <p className="mt-2 text-2xl font-bold text-foreground"> - ₦{totalEarned.toLocaleString()} - </p> - </div> - <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary"> - <DollarSign className="h-6 w-6 text-foreground" /> - </div> - </div> - </Card> - - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-center justify-between"> - <div> - <p className="text-sm font-medium text-muted-foreground"> - Paid - </p> - <p className="mt-2 text-2xl font-bold text-foreground"> - ₦{paidAmount.toLocaleString()} - </p> - </div> - <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500"> - <CheckCircle className="h-6 w-6 text-foreground" /> - </div> - </div> - </Card> - - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-center justify-between"> - <div> - <p className="text-sm font-medium text-muted-foreground"> - Pending - </p> - <p className="mt-2 text-2xl font-bold text-foreground"> - ₦{pendingAmount.toLocaleString()} - </p> - </div> - <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-accent"> - <Clock className="h-6 w-6 text-foreground" /> + {[ + { + label: "Total Earned", + amount: totalEarned, + icon: DollarSign, + iconClass: "bg-primary", + }, + { + label: "Paid", + amount: paidAmount, + icon: CheckCircle, + iconClass: "bg-green-500", + }, + { + label: "Pending", + amount: pendingAmount, + icon: Clock, + iconClass: "bg-accent", + }, + ].map((stat) => ( + <Card + key={stat.label} + className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" + > + <div className="flex items-center justify-between"> + <div> + <p className="text-sm font-medium text-muted-foreground"> + {stat.label} + </p> + <p className="mt-2 text-2xl font-bold text-foreground"> + <MoneyValue + status={moneyStatus} + amount={stat.amount} + format={formatNaira} + skeletonClassName="h-8 w-32" + unavailableLabel={`${stat.label} unavailable`} + /> + </p> + </div> + <div + className={`flex h-12 w-12 items-center justify-center rounded-lg ${stat.iconClass}`} + > + <stat.icon className="h-6 w-6 text-foreground" aria-hidden="true" /> + </div> </div> - </div> - </Card> + </Card> + ))} </div> )} @@ -166,21 +175,25 @@ export default function EarningsPage() { </h3> {isLoading ? ( - <div className="space-y-4"> + <LoadingState label="Loading earnings history" className="space-y-4"> {[1, 2, 3].map((i) => ( - <Skeleton key={i} className="h-24 border-3 border-foreground" /> + <ListRowSkeleton key={i} /> ))} - </div> - ) : !earnings || earnings.inspections.length === 0 ? ( - <div className="py-12 text-center"> - <DollarSign className="mx-auto h-16 w-16 text-muted-foreground" /> - <h3 className="mt-4 text-xl font-bold text-foreground"> - No earnings yet - </h3> - <p className="mt-2 text-muted-foreground"> - Complete inspection jobs to start earning. - </p> - </div> + </LoadingState> + ) : error || !earnings ? ( + <ErrorState + title="Earnings history is unavailable" + description={error ?? "We couldn't reach the inspections service."} + onRetry={fetchEarnings} + retryLabel="Retry" + /> + ) : earnings.inspections.length === 0 ? ( + <EmptyState + icon={DollarSign} + title="No earnings yet" + description="Claim an inspection job and submit your report — approved inspections are paid out here." + action={{ label: "Find inspection jobs", href: "/dashboard/inspector" }} + /> ) : ( <div className="space-y-4"> {earnings.inspections.map((inspection) => ( @@ -205,7 +218,11 @@ export default function EarningsPage() { <div className="flex items-center gap-4"> <div className="text-right"> <p className="text-lg font-bold text-foreground"> - ₦{inspection.fee.toLocaleString()} + <MoneyValue + status="ready" + amount={inspection.fee} + format={formatNaira} + /> </p> <Badge className="border-2 border-foreground bg-green-500"> Paid diff --git a/frontend/app/dashboard/inspector/page.tsx b/frontend/app/dashboard/inspector/page.tsx index fb237a165..c48a2f7ce 100644 --- a/frontend/app/dashboard/inspector/page.tsx +++ b/frontend/app/dashboard/inspector/page.tsx @@ -12,6 +12,12 @@ import { import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { + EmptyState, + ErrorState, + LoadingState, + StatCardSkeleton, +} from "@/components/ui/data-state"; import { DashboardHeader } from "@/components/dashboard-header"; import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar"; import { JobCard } from "@/components/inspector/JobCard"; @@ -126,25 +132,25 @@ export default function InspectorDashboard() { {/* Error */} {error && !isLoading && ( - <Card className="mb-8 border-3 border-foreground p-6 text-center shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <p className="text-destructive">{error}</p> - <Button - onClick={fetchJobs} - className="mt-4 border-3 border-foreground bg-primary shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - <RefreshCw className="mr-2 h-4 w-4" /> - Retry - </Button> - </Card> + <ErrorState + className="mb-8" + title="Failed to load inspection jobs" + description={error} + onRetry={fetchJobs} + retryLabel="Retry" + /> )} {/* Stats */} {isLoading ? ( - <div className="mb-8 grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <LoadingState + label="Loading inspection stats" + className="mb-8 grid gap-4 md:grid-cols-2 lg:grid-cols-4" + > {[1, 2, 3, 4].map((i) => ( - <Skeleton key={i} className="h-32 border-3 border-foreground" /> + <StatCardSkeleton key={i} className="h-32" /> ))} - </div> + </LoadingState> ) : ( <div className="mb-8 grid gap-4 md:grid-cols-2 lg:grid-cols-4"> {stats.map((stat, index) => { @@ -207,23 +213,29 @@ export default function InspectorDashboard() { {/* Job Board */} {isLoading ? ( - <div className="grid gap-6 md:grid-cols-2"> + <LoadingState + label="Loading inspection jobs" + className="grid gap-6 md:grid-cols-2" + > {[1, 2, 3, 4].map((i) => ( <Skeleton key={i} className="h-48 border-3 border-foreground" /> ))} - </div> + </LoadingState> ) : filteredJobs.length === 0 ? ( - <Card className="border-3 border-foreground p-12 text-center shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <FileText className="mx-auto h-16 w-16 text-muted-foreground" /> - <h3 className="mt-4 text-xl font-bold text-foreground"> - No jobs found - </h3> - <p className="mt-2 text-muted-foreground"> - {filter === "all" - ? "There are no inspection jobs available at the moment." - : `There are no ${filter.replace("_", " ")} jobs.`} - </p> - </Card> + <EmptyState + icon={FileText} + title="No jobs found" + description={ + filter === "all" + ? "There are no inspection jobs available right now. New jobs appear as landlords list properties — check back shortly." + : `No ${filter.replace("_", " ")} jobs. Switch to "All Jobs" to see everything on the board.` + } + action={ + filter === "all" + ? { label: "Refresh job board", onClick: fetchJobs } + : { label: "Show all jobs", onClick: () => setFilter("all") } + } + /> ) : ( <div className="grid gap-6 md:grid-cols-2"> {filteredJobs.map((job) => ( diff --git a/frontend/app/dashboard/landlord/page.tsx b/frontend/app/dashboard/landlord/page.tsx index 0a95e5b64..d61ca066d 100644 --- a/frontend/app/dashboard/landlord/page.tsx +++ b/frontend/app/dashboard/landlord/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { Plus, @@ -15,12 +15,17 @@ import { Edit, Trash2, Eye, - AlertTriangle, - Loader2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { + EmptyState, + ErrorState, + LoadingState, + MoneyValue, + StatCardSkeleton, +} from "@/components/ui/data-state"; import { DropdownMenu, DropdownMenuContent, @@ -54,7 +59,7 @@ export default function LandlordDashboard() { const [propertiesLoading, setPropertiesLoading] = useState(true); const [propertiesError, setPropertiesError] = useState<string | null>(null); - useEffect(() => { + const loadStats = useCallback(() => { getLandlordDashboardStats() .then((data) => { setStats(data); @@ -69,7 +74,7 @@ export default function LandlordDashboard() { .finally(() => setStatsLoading(false)); }, []); - useEffect(() => { + const loadProperties = useCallback(() => { listLandlordProperties() .then(async (res) => { setProperties(res.properties); @@ -103,6 +108,28 @@ export default function LandlordDashboard() { .finally(() => setPropertiesLoading(false)); }, []); + useEffect(() => { + loadStats(); + }, [loadStats]); + + useEffect(() => { + loadProperties(); + }, [loadProperties]); + + // 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 retryStats = useCallback(() => { + setStatsLoading(true); + setStatsError(null); + loadStats(); + }, [loadStats]); + + const retryProperties = useCallback(() => { + setPropertiesLoading(true); + setPropertiesError(null); + loadProperties(); + }, [loadProperties]); + const formatCurrency = (amount: number) => { return new Intl.NumberFormat("en-NG", { style: "currency", @@ -111,30 +138,34 @@ export default function LandlordDashboard() { }).format(amount); }; + // `money` entries go through MoneyValue so an absent figure renders as a dash + // rather than as ₦0, which a landlord could reasonably read as "earned zero". const statsData = [ { label: "Total Properties", - value: stats?.totalProperties.toString() || "0", + value: stats?.totalProperties ?? null, + money: false, icon: Building2, color: "bg-primary", }, { label: "Active Listings", - value: stats?.activeListings.toString() || "0", + value: stats?.activeListings ?? null, + money: false, icon: Building2, color: "bg-secondary", }, { label: "Total Views", - value: stats?.totalViews.toString() || "0", + value: stats?.totalViews ?? null, + money: false, icon: Eye, color: "bg-accent", }, { label: "Monthly Revenue", - value: stats?.monthlyRevenueNgn - ? formatCurrency(stats.monthlyRevenueNgn) - : "₦0", + value: stats?.monthlyRevenueNgn ?? null, + money: true, icon: Building2, color: "bg-primary", }, @@ -168,33 +199,25 @@ export default function LandlordDashboard() { </Link> </div> - <div className="mb-6 grid grid-cols-2 gap-3 md:mb-8 md:grid-cols-4 md:gap-6"> - {statsLoading ? ( - Array.from({ length: 4 }).map((_, index) => ( - <Card - key={`stats-loading-${index}`} - className="border-3 border-foreground p-3 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] md:p-6" - > - <div className="space-y-2"> - <Skeleton className="h-5 w-20" /> - <Skeleton className="h-8 w-16" /> - </div> - </Card> - )) - ) : statsError ? ( - <Card className="col-span-2 border-3 border-foreground bg-destructive/10 p-4 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] md:col-span-4 md:p-6"> - <div className="flex items-start gap-3"> - <AlertTriangle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold">Stats are currently unavailable</p> - <p className="text-sm text-muted-foreground"> - {statsError} - </p> - </div> - </div> - </Card> - ) : ( - statsData.map((stat) => ( + {statsLoading ? ( + <LoadingState + label="Loading portfolio stats" + className="mb-6 grid grid-cols-2 gap-3 md:mb-8 md:grid-cols-4 md:gap-6" + > + {Array.from({ length: 4 }).map((_, index) => ( + <StatCardSkeleton key={`stats-loading-${index}`} /> + ))} + </LoadingState> + ) : statsError ? ( + <ErrorState + className="mb-6 md:mb-8" + title="Stats are currently unavailable" + description={statsError} + onRetry={retryStats} + /> + ) : ( + <div className="mb-6 grid grid-cols-2 gap-3 md:mb-8 md:grid-cols-4 md:gap-6"> + {statsData.map((stat) => ( <Card key={stat.label} className="border-3 border-foreground p-3 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] md:p-6" @@ -210,14 +233,22 @@ export default function LandlordDashboard() { {stat.label} </p> <p className="truncate text-xl font-bold text-foreground md:text-3xl"> - {stat.value} + {stat.money ? ( + <MoneyValue + status="ready" + amount={stat.value} + format={formatCurrency} + /> + ) : ( + (stat.value?.toLocaleString() ?? "—") + )} </p> </div> </div> </Card> - )) - )} - </div> + ))} + </div> + )} <div className="mb-6 flex flex-wrap gap-2 md:gap-4" @@ -241,35 +272,34 @@ export default function LandlordDashboard() { {activeTab === "properties" && ( <div className="grid gap-6"> {propertiesLoading ? ( - Array.from({ length: 2 }).map((_, index) => ( - <Card - key={`properties-loading-${index}`} - className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" - > - <Skeleton className="mb-4 h-6 w-56" /> - <Skeleton className="mb-2 h-4 w-40" /> - <Skeleton className="h-32 w-full" /> - </Card> - )) + <LoadingState label="Loading your properties" className="grid gap-6"> + {Array.from({ length: 2 }).map((_, index) => ( + <Card + key={`properties-loading-${index}`} + className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" + > + <Skeleton className="mb-4 h-6 w-56" /> + <Skeleton className="mb-2 h-4 w-40" /> + <Skeleton className="h-32 w-full" /> + </Card> + ))} + </LoadingState> ) : propertiesError ? ( - <Card className="border-3 border-foreground bg-destructive/10 p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-start gap-3"> - <AlertTriangle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold">Property data is unavailable</p> - <p className="text-sm text-muted-foreground"> - {propertiesError} - </p> - </div> - </div> - </Card> + <ErrorState + title="Property data is unavailable" + description={propertiesError} + onRetry={retryProperties} + /> ) : properties.length === 0 ? ( - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <p className="font-bold">No properties yet</p> - <p className="text-sm text-muted-foreground"> - Add your first property to get started - </p> - </Card> + <EmptyState + icon={Building2} + title="No properties yet" + description="List your first property to start receiving applications from verified tenants." + action={{ + label: "Add your first property", + href: "/dashboard/landlord/properties/new", + }} + /> ) : ( properties.map((property) => { let statusBadgeClassName = "bg-muted"; @@ -391,7 +421,11 @@ export default function LandlordDashboard() { <div className="mt-auto flex items-center justify-between"> <div className="flex items-center gap-6"> <p className="text-2xl font-bold text-primary"> - {formatCurrency(property.annualRentNgn)} + <MoneyValue + status="ready" + amount={property.annualRentNgn} + format={formatCurrency} + /> <span className="text-sm font-normal text-muted-foreground"> /year </span> diff --git a/frontend/app/dashboard/landlord/payouts/page.tsx b/frontend/app/dashboard/landlord/payouts/page.tsx index 36f207b7e..56d81c3eb 100644 --- a/frontend/app/dashboard/landlord/payouts/page.tsx +++ b/frontend/app/dashboard/landlord/payouts/page.tsx @@ -10,6 +10,12 @@ import { 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, + LoadingState, +} from "@/components/ui/data-state"; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, } from "@/components/ui/table"; @@ -232,30 +238,45 @@ export default function LandlordPayoutSchedulePage() { {/* Timeline */} {loading ? ( - <div className="space-y-4" role="status" aria-label="Loading payout schedule"> - <p className="sr-only">Loading payout schedule...</p> + <LoadingState label="Loading payout schedule" className="space-y-4"> {Array.from({ length: 3 }).map((_, i) => ( - <Card key={i} className="animate-pulse border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="h-6 w-32 bg-muted rounded" /> - <div className="mt-4 h-4 w-48 bg-muted rounded" /> + <Card key={i} className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + <Skeleton className="h-6 w-32" /> + <Skeleton className="mt-4 h-4 w-48" /> </Card> ))} - </div> + </LoadingState> ) : error ? ( - <Card className="border-3 border-destructive p-6 text-center" role="alert"> - <p className="mt-4 font-bold text-destructive">{error}</p> - <Button onClick={fetchData} className="mt-4 border-2 border-foreground font-bold" aria-label="Retry loading payout schedule"> - Retry - </Button> - </Card> + <ErrorState + title="Payout schedule is unavailable" + description={error} + onRetry={fetchData} + retryLabel="Retry" + /> ) : periods.length === 0 ? ( - <Card className="border-3 border-foreground p-8 text-center shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <BarChart3 className="mx-auto h-16 w-16 text-muted-foreground" aria-hidden="true" /> - <p className="mt-4 text-lg font-bold">No payouts scheduled</p> - <p className="mt-2 text-sm text-muted-foreground"> - {statusFilter || channelFilter ? "Try adjusting your filters" : "Payouts will appear here once scheduled"} - </p> - </Card> + <EmptyState + icon={BarChart3} + title="No payouts scheduled" + description={ + statusFilter || channelFilter + ? "No payout periods match these filters. Clearing them shows your full schedule." + : "Payouts appear here once a tenant pays rent on one of your properties. Add a payout account so we can send funds the moment they clear." + } + action={ + statusFilter || channelFilter + ? { + label: "Clear filters", + onClick: () => { + setStatusFilter(""); + setChannelFilter(""); + }, + } + : { + label: "Set up payouts", + href: "/dashboard/landlord/settings/payouts", + } + } + /> ) : ( <div className="space-y-4" role="list" aria-label="Payout periods"> {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! </h1> {leaseLoading ? ( - <div className="mt-2 flex items-center gap-2" role="status"> - <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> - <span className="text-sm text-muted-foreground"> - Loading lease info... - </span> - </div> + <LoadingState + label="Loading your lease summary" + className="mt-2 flex h-6 items-center md:h-7 lg:h-8" + > + <Skeleton className="h-4 w-72 max-w-full" /> + </LoadingState> + ) : leaseError ? ( + <p className="mt-2 text-sm text-muted-foreground md:text-base lg:text-lg"> + We couldn't load your lease just now. + </p> ) : currentLease ? ( <p className="mt-2 text-sm text-muted-foreground md:text-base lg:text-lg"> Your next payment of{" "} - {formatCurrency(currentLease.monthlyPayment)} is due on{" "} + <MoneyValue + status="ready" + amount={currentLease.monthlyPayment} + format={formatCurrency} + className="font-bold text-foreground" + />{" "} + is due on{" "} {new Date(currentLease.nextPaymentDate).toLocaleDateString()} </p> ) : ( @@ -256,19 +318,23 @@ export default function TenantDashboard() { )} </div> - {leaseError && ( - <Card className="mb-6 border-3 border-foreground bg-destructive/10 p-4"> - <div className="flex items-start gap-3"> - <AlertCircle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold">Failed to load lease information</p> - <p className="text-sm text-muted-foreground">{leaseError}</p> - </div> - </div> - </Card> - )} - - {currentLease && !leaseError && ( + {leaseLoading ? ( + <LoadingState + label="Loading lease totals" + className="mb-6 grid grid-cols-2 gap-3 md:mb-8 md:grid-cols-4 md:gap-4" + > + {Array.from({ length: 4 }).map((_, index) => ( + <StatCardSkeleton key={`lease-stat-${index}`} className="md:p-4" /> + ))} + </LoadingState> + ) : leaseError ? ( + <ErrorState + className="mb-6 md:mb-8" + title="Failed to load lease information" + description={leaseError} + onRetry={retryLease} + /> + ) : currentLease ? ( <div className="mb-6 grid grid-cols-2 gap-3 md:mb-8 md:grid-cols-4 md:gap-4"> <Card className="border-3 border-foreground p-3 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] md:p-4"> <div className="flex items-center gap-2 md:gap-3"> @@ -280,7 +346,11 @@ export default function TenantDashboard() { Next Payment </p> <p className="truncate text-base font-bold md:text-xl"> - {formatCurrency(currentLease.monthlyPayment)} + <MoneyValue + status="ready" + amount={currentLease.monthlyPayment} + format={formatCurrency} + /> </p> </div> </div> @@ -295,7 +365,11 @@ export default function TenantDashboard() { Total Paid </p> <p className="truncate text-base font-bold md:text-xl"> - {formatCurrency(currentLease.totalPaid)} + <MoneyValue + status="ready" + amount={currentLease.totalPaid} + format={formatCurrency} + /> </p> </div> </div> @@ -310,9 +384,11 @@ export default function TenantDashboard() { Remaining </p> <p className="truncate text-base font-bold md:text-xl"> - {formatCurrency( - currentLease.totalOwed - currentLease.totalPaid, - )} + <MoneyValue + status="ready" + amount={remainingBalance} + format={formatCurrency} + /> </p> </div> </div> @@ -333,7 +409,7 @@ export default function TenantDashboard() { </div> </Card> </div> - )} + ) : null} <div className="mb-6 flex flex-wrap gap-2 md:gap-4" @@ -365,11 +441,21 @@ export default function TenantDashboard() { <SectionBoundary section="tenant-dashboard-overview" userRole="tenant"> <div className="grid gap-4 md:gap-6 lg:grid-cols-2"> {leaseLoading ? ( - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-8 w-8 animate-spin" /> - </div> - </Card> + <LoadingState label="Loading your current home"> + <Card className="space-y-4 border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + <Skeleton className="h-6 w-40" /> + <Skeleton className="h-40 w-full" /> + <Skeleton className="h-6 w-56" /> + <Skeleton className="h-4 w-40" /> + <Skeleton className="h-12 w-full" /> + </Card> + </LoadingState> + ) : leaseError ? ( + <ErrorState + title="Failed to load your current home" + description={leaseError} + onRetry={retryLease} + /> ) : currentLease ? ( <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> <h3 className="mb-4 text-lg font-bold">Your Current Home</h3> @@ -408,13 +494,12 @@ export default function TenantDashboard() { </div> </Card> ) : ( - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <p className="font-bold">No active lease</p> - <p className="text-sm text-muted-foreground"> - You don't have an active lease at the moment. Browse - properties to get started. - </p> - </Card> + <EmptyState + icon={Building2} + title="No active lease" + description="Once you're approved for a property, your lease, landlord, and payment schedule will show up here." + action={{ label: "Browse properties", href: "/properties" }} + /> )} <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> @@ -444,19 +529,46 @@ export default function TenantDashboard() { </div> <div className="mt-2 flex justify-between text-sm text-muted-foreground"> <span> - {formatCurrency(currentLease.totalPaid)} paid + <MoneyValue + status="ready" + amount={currentLease.totalPaid} + format={formatCurrency} + />{" "} + paid </span> <span> - {formatCurrency(currentLease.totalOwed)} total + <MoneyValue + status="ready" + amount={currentLease.totalOwed} + format={formatCurrency} + />{" "} + total </span> </div> </div> <h4 className="mb-3 font-bold">Upcoming Payments</h4> {paymentsLoading ? ( - <div className="flex items-center justify-center py-4"> - <Loader2 className="h-6 w-6 animate-spin" /> - </div> + <LoadingState + label="Loading upcoming payments" + className="space-y-2" + > + {Array.from({ length: 3 }).map((_, index) => ( + <div + key={`upcoming-${index}`} + className="flex items-center justify-between border-b border-foreground/10 pb-2" + > + <Skeleton className="h-5 w-28" /> + <Skeleton className="h-5 w-24" /> + </div> + ))} + </LoadingState> + ) : paymentsError ? ( + <ErrorState + title="Failed to load upcoming payments" + description={paymentsError} + onRetry={retryPayments} + /> ) : paymentSchedule.length > 0 ? ( <div className="space-y-2"> {paymentSchedule.slice(0, 3).map((payment) => ( @@ -481,14 +593,19 @@ export default function TenantDashboard() { </span> </div> <span className="font-mono font-bold"> - {formatCurrency(payment.amount)} + <MoneyValue + status="ready" + amount={payment.amount} + format={formatCurrency} + /> </span> </div> ))} </div> ) : ( <p className="text-sm text-muted-foreground"> - No upcoming payments + You're all paid up — no upcoming instalments on this + lease. </p> )} </> @@ -511,23 +628,27 @@ export default function TenantDashboard() { <h3 className="mb-6 text-lg font-bold">Payment History</h3> {paymentsLoading ? ( - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-8 w-8 animate-spin" /> - </div> + <LoadingState + label="Loading payment history" + className="space-y-3" + > + {Array.from({ length: 4 }).map((_, index) => ( + <ListRowSkeleton key={`payment-${index}`} /> + ))} + </LoadingState> ) : paymentsError ? ( - <div className="flex items-start gap-3"> - <AlertCircle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold">Failed to load payments</p> - <p className="text-sm text-muted-foreground"> - {paymentsError} - </p> - </div> - </div> + <ErrorState + title="Failed to load payments" + description={paymentsError} + onRetry={retryPayments} + /> ) : allPayments.length === 0 ? ( - <p className="text-sm text-muted-foreground"> - No payment history yet - </p> + <EmptyState + icon={Receipt} + title="No payment history yet" + description="Payments you make on your lease will be listed here with their receipts and status." + action={{ label: "Go to payments", href: "/dashboard/tenant/payments" }} + /> ) : ( <div className="space-y-3"> {allPayments.map((payment) => { @@ -564,7 +685,11 @@ export default function TenantDashboard() { </div> <div className="text-right"> <p className="font-mono font-bold"> - {formatCurrency(payment.amount)} + <MoneyValue + status="ready" + amount={payment.amount} + format={formatCurrency} + /> </p> <Badge variant={presentation.statusPresentation.variant} @@ -588,30 +713,21 @@ export default function TenantDashboard() { <SectionBoundary section="tenant-dashboard-saved" userRole="tenant"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {savedLoading ? ( - Array.from({ length: 3 }).map((_, i) => ( - <Card - key={i} - className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" - > - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-8 w-8 animate-spin" /> - </div> - </Card> - )) + <LoadingState + label="Loading saved properties" + className="col-span-full grid gap-4 md:grid-cols-2 lg:grid-cols-3" + > + {Array.from({ length: 3 }).map((_, i) => ( + <PropertyCardSkeleton key={`saved-${i}`} /> + ))} + </LoadingState> ) : savedError ? ( - <Card className="border-3 border-foreground bg-destructive/10 p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] col-span-full"> - <div className="flex items-start gap-3"> - <AlertCircle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold"> - Failed to load saved properties - </p> - <p className="text-sm text-muted-foreground"> - {savedError} - </p> - </div> - </div> - </Card> + <ErrorState + className="col-span-full" + title="Failed to load saved properties" + description={savedError} + onRetry={retrySaved} + /> ) : savedProperties.length > 0 ? ( <> {savedProperties.map((property) => ( @@ -633,12 +749,13 @@ export default function TenantDashboard() { </Card> </> ) : ( - <Card className="border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] col-span-full"> - <p className="font-bold">No saved properties</p> - <p className="text-sm text-muted-foreground"> - Browse properties and save your favorites here - </p> - </Card> + <EmptyState + className="col-span-full" + icon={Heart} + title="No saved properties yet" + description="Tap the heart on any listing to keep it here, so you can compare your shortlist later." + action={{ label: "Browse properties", href: "/properties" }} + /> )} </div> </SectionBoundary> diff --git a/frontend/app/dashboard/user/page.tsx b/frontend/app/dashboard/user/page.tsx index 50b084931..3cc810273 100644 --- a/frontend/app/dashboard/user/page.tsx +++ b/frontend/app/dashboard/user/page.tsx @@ -1,24 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; -import { - Building2, - CreditCard, - Wallet, - Loader2, - AlertTriangle, -} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Building2, CreditCard, Wallet } from "lucide-react"; import { DashboardHeader } from "@/components/dashboard-header"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; + EmptyState, + ErrorState, + ListRowSkeleton, + LoadingState, + MoneyValue, + StatCardSkeleton, +} from "@/components/ui/data-state"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { UserPropertyCard } from "@/components/user-dashboard/UserPropertyCard"; import { ApplicationsTable } from "@/components/user-dashboard/ApplicationsTable"; @@ -63,7 +57,7 @@ export default function UserDashboardPage() { const [walletLoading, setWalletLoading] = useState(true); const [walletError, setWalletError] = useState<string | null>(null); - useEffect(() => { + const loadSaved = useCallback(() => { fetchSavedListingIds() .then((ids) => { if (ids.length === 0) { @@ -91,7 +85,7 @@ export default function UserDashboardPage() { .finally(() => setSavedLoading(false)); }, []); - useEffect(() => { + const loadApplications = useCallback(() => { listTenantApplications() .then((res) => { const apps: UserRentalApplication[] = res.data.map((app) => ({ @@ -116,7 +110,7 @@ export default function UserDashboardPage() { .finally(() => setAppsLoading(false)); }, []); - useEffect(() => { + const loadWallet = useCallback(() => { Promise.all([getNgnBalance(), getNgnLedger({ limit: 20 })]) .then(([balanceRes, ledgerRes]) => { const balance: WalletBalance = { @@ -150,6 +144,38 @@ export default function UserDashboardPage() { .finally(() => setWalletLoading(false)); }, []); + useEffect(() => { + loadSaved(); + }, [loadSaved]); + + useEffect(() => { + loadApplications(); + }, [loadApplications]); + + useEffect(() => { + loadWallet(); + }, [loadWallet]); + + // 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 retrySaved = useCallback(() => { + setSavedLoading(true); + setSavedError(null); + loadSaved(); + }, [loadSaved]); + + const retryApplications = useCallback(() => { + setAppsLoading(true); + setAppsError(null); + loadApplications(); + }, [loadApplications]); + + const retryWallet = useCallback(() => { + setWalletLoading(true); + setWalletError(null); + loadWallet(); + }, [loadWallet]); + return ( <div className="min-h-screen bg-background"> <DashboardHeader /> @@ -189,38 +215,27 @@ export default function UserDashboardPage() { <TabsContent value="my-properties" className="mt-4"> {savedLoading ? ( - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + <LoadingState + label="Loading saved properties" + className="grid gap-4 md:grid-cols-2 lg:grid-cols-3" + > {Array.from({ length: 6 }).map((_, i) => ( <Skeleton key={i} className="h-24 w-full" /> ))} - </div> + </LoadingState> ) : savedError ? ( - <Card className="border-3 border-foreground bg-destructive/10 p-6"> - <div className="flex items-start gap-3"> - <AlertTriangle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold"> - Failed to load saved properties - </p> - <p className="text-sm text-muted-foreground"> - {savedError} - </p> - </div> - </div> - </Card> + <ErrorState + title="Failed to load saved properties" + description={savedError} + onRetry={retrySaved} + /> ) : savedProperties.length === 0 ? ( - <Empty className="border-2 border-foreground/20 bg-card"> - <EmptyHeader> - <EmptyMedia variant="icon"> - <Building2 /> - </EmptyMedia> - <EmptyTitle>No saved properties yet</EmptyTitle> - <EmptyDescription> - Shortlist properties to see them here. - </EmptyDescription> - </EmptyHeader> - <EmptyContent /> - </Empty> + <EmptyState + icon={Building2} + title="No saved properties yet" + description="Shortlist properties as you browse and they'll be waiting for you here." + action={{ label: "Browse properties", href: "/properties" }} + /> ) : ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {savedProperties.map((p) => ( @@ -232,33 +247,24 @@ export default function UserDashboardPage() { <TabsContent value="applications" className="mt-4"> {appsLoading ? ( - <Skeleton className="h-64 w-full" /> + <LoadingState label="Loading applications" className="space-y-3"> + {Array.from({ length: 4 }).map((_, i) => ( + <ListRowSkeleton key={i} /> + ))} + </LoadingState> ) : appsError ? ( - <Card className="border-3 border-foreground bg-destructive/10 p-6"> - <div className="flex items-start gap-3"> - <AlertTriangle className="h-5 w-5 shrink-0 text-destructive" /> - <div> - <p className="font-bold">Failed to load applications</p> - <p className="text-sm text-muted-foreground"> - {appsError} - </p> - </div> - </div> - </Card> + <ErrorState + title="Failed to load applications" + description={appsError} + onRetry={retryApplications} + /> ) : applications.length === 0 ? ( - <Empty className="border-2 border-foreground/20 bg-card"> - <EmptyHeader> - <EmptyMedia variant="icon"> - <CreditCard /> - </EmptyMedia> - <EmptyTitle>No applications yet</EmptyTitle> - <EmptyDescription> - When you submit rental applications, they will appear - here. - </EmptyDescription> - </EmptyHeader> - <EmptyContent /> - </Empty> + <EmptyState + icon={CreditCard} + title="No applications yet" + description="Apply for a property you like and you'll be able to track its progress from here." + action={{ label: "Find a property", href: "/properties" }} + /> ) : ( <Card className="border-2 border-foreground/20"> <CardHeader> @@ -273,26 +279,24 @@ export default function UserDashboardPage() { <TabsContent value="wallet" className="mt-4"> {walletLoading ? ( - <div className="grid gap-4"> + <LoadingState label="Loading wallet balance" className="grid gap-4"> <div className="grid gap-4 md:grid-cols-3"> - <Skeleton className="h-28 w-full" /> - <Skeleton className="h-28 w-full" /> - <Skeleton className="h-28 w-full" /> + <StatCardSkeleton className="h-28" /> + <StatCardSkeleton className="h-28" /> + <StatCardSkeleton className="h-28" /> </div> - <Skeleton className="h-64 w-full" /> - </div> - ) : walletError ? ( - <Card className="border-3 border-foreground bg-destructive/10 p-6"> - <div className="flex items-start gap-3"> - <AlertTriangle className="h-5 w-5 shrink-0 text-destructive" aria-hidden="true" /> - <div> - <p className="font-bold">Failed to load wallet</p> - <p className="text-sm text-muted-foreground"> - {walletError} - </p> - </div> + <div className="space-y-3"> + {Array.from({ length: 4 }).map((_, i) => ( + <ListRowSkeleton key={i} /> + ))} </div> - </Card> + </LoadingState> + ) : walletError ? ( + <ErrorState + title="Failed to load wallet" + description={walletError} + onRetry={retryWallet} + /> ) : walletBalance ? ( <div className="grid gap-4"> <div className="grid gap-4 md:grid-cols-3"> @@ -302,10 +306,22 @@ export default function UserDashboardPage() { </CardHeader> <CardContent> <div className="font-mono text-2xl font-black text-primary"> - {formatNgn(walletBalance.availableNgn)} + <MoneyValue + status="ready" + amount={walletBalance.availableNgn} + format={formatNgn} + unavailableLabel="Available balance unavailable" + /> </div> <div className="mt-1 text-xs text-muted-foreground"> - Held: {formatNgn(walletBalance.heldNgn)} + Held:{" "} + <MoneyValue + status="ready" + amount={walletBalance.heldNgn} + format={formatNgn} + skeletonClassName="h-3 w-16" + unavailableLabel="Held balance unavailable" + /> </div> </CardContent> </Card> @@ -331,7 +347,12 @@ export default function UserDashboardPage() { <CardContent> <div className="text-sm text-muted-foreground">NGN</div> <div className="font-mono font-black text-foreground"> - {formatNgn(walletBalance.totalNgn)} + <MoneyValue + status="ready" + amount={walletBalance.totalNgn} + format={formatNgn} + unavailableLabel="Total NGN unavailable" + /> </div> <div className="mt-3 text-sm text-muted-foreground"> USDC @@ -344,18 +365,12 @@ export default function UserDashboardPage() { </div> {ledgerEntries.length === 0 ? ( - <Empty className="border-2 border-foreground/20 bg-card"> - <EmptyHeader> - <EmptyMedia variant="icon"> - <Wallet aria-hidden="true" /> - </EmptyMedia> - <EmptyTitle>No transactions yet</EmptyTitle> - <EmptyDescription> - Your wallet ledger entries will appear here. - </EmptyDescription> - </EmptyHeader> - <EmptyContent /> - </Empty> + <EmptyState + icon={Wallet} + title="No transactions yet" + description="Top up your wallet to cover rent instalments — every movement shows up in this ledger." + action={{ label: "Go to wallet", href: "/wallet" }} + /> ) : ( <Card className="border-2 border-foreground/20"> <CardHeader> diff --git a/frontend/app/design-system/page.tsx b/frontend/app/design-system/page.tsx index 960b9256f..1ecedb387 100644 --- a/frontend/app/design-system/page.tsx +++ b/frontend/app/design-system/page.tsx @@ -27,7 +27,7 @@ import { ThemeToggle } from "@/components/theme-toggle" const isDevelopment = process.env.NODE_ENV === "development" export const metadata: Metadata = { - title: "Design System — Shelterflex", + title: "Design System", robots: { index: false, follow: false }, } diff --git a/frontend/app/forgot-password/layout.tsx b/frontend/app/forgot-password/layout.tsx new file mode 100644 index 000000000..6a78437cc --- /dev/null +++ b/frontend/app/forgot-password/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Account recovery step — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Reset Password"); + +export default function ForgotPasswordLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/landlords/LandlordsClient.tsx b/frontend/app/landlords/LandlordsClient.tsx new file mode 100644 index 000000000..85d5b09c9 --- /dev/null +++ b/frontend/app/landlords/LandlordsClient.tsx @@ -0,0 +1,500 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState, useEffect } from "react"; +import { + ArrowRight, + Check, + Shield, + Zap, + TrendingUp, + Building, + Banknote, + Loader2, + CheckCircle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { landlordBenefits } from "@/lib/landlordBenefits"; +import { getPublicLandlordStats } from "@/lib/publicStatsApi"; +import type { LandlordPublicStats } from "@/lib/publicStatsApi"; + +const API_BASE = + process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000"; + +const iconMap: Record<string, ReactNode> = { + "Get Paid Upfront": <Banknote className="h-10 w-10" />, + "Zero Default Risk": <Shield className="h-10 w-10" />, + "Verified Tenants": <Check className="h-10 w-10" />, + "Fill Vacancies Faster": <TrendingUp className="h-10 w-10" />, + "Quick Onboarding": <Zap className="h-10 w-10" />, + "Property Management": <Building className="h-10 w-10" />, +}; + +const fallbackStats: LandlordPublicStats = { + totalPaidToLandlords: "-", + partnerLandlords: "-", + avgPaymentTime: "-", + landlordDefaultRate: "-", +}; + +const statsConfig: { key: keyof LandlordPublicStats; label: string }[] = [ + { key: "totalPaidToLandlords", label: "Paid to Landlords" }, + { key: "partnerLandlords", label: "Partner Landlords" }, + { key: "avgPaymentTime", label: "Avg. Payment Time" }, + { key: "landlordDefaultRate", label: "Default Rate" }, +]; + +export default function LandlordsClient() { + const [partnerForm, setPartnerForm] = useState({ + fullName: "", + phone: "", + email: "", + propertyCount: "", + propertyLocations: "", + }); + const [partnerSubmitting, setPartnerSubmitting] = useState(false); + const [partnerError, setPartnerError] = useState<string | null>(null); + const [partnerSuccess, setPartnerSuccess] = useState(false); + const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); + const [stats, setStats] = useState<LandlordPublicStats>(fallbackStats); + const [statsLoading, setStatsLoading] = useState(true); + + useEffect(() => { + getPublicLandlordStats() + .then(setStats) + .catch(() => setStats(fallbackStats)) + .finally(() => setStatsLoading(false)); + }, []); + + const validatePartnerForm = () => { + const errors: Record<string, string> = {}; + if (!partnerForm.fullName.trim()) errors.fullName = "Full name is required."; + if (!partnerForm.phone.trim()) errors.phone = "Phone number is required."; + if (!partnerForm.email.trim()) errors.email = "Email is required."; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(partnerForm.email)) + errors.email = "Enter a valid email address."; + if (!partnerForm.propertyCount) errors.propertyCount = "Number of properties is required."; + return errors; + }; + + const handlePartnerSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setPartnerError(null); + setFieldErrors({}); + + const errors = validatePartnerForm(); + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); + return; + } + + setPartnerSubmitting(true); + try { + const res = await fetch(`${API_BASE}/api/landlord/partner-application`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + fullName: partnerForm.fullName, + phone: partnerForm.phone, + email: partnerForm.email, + propertyCount: parseInt(partnerForm.propertyCount, 10), + propertyLocations: partnerForm.propertyLocations, + }), + }); + + if (res.status === 404) { + setPartnerSuccess(true); + return; + } + + const data = await res.json() as { error?: { message?: string }; message?: string }; + + if (!res.ok) { + setPartnerError( + data?.error?.message || data?.message || "Submission failed. Please try again.", + ); + return; + } + + setPartnerSuccess(true); + } catch { + setPartnerError("Network error — please check your connection and try again."); + } finally { + setPartnerSubmitting(false); + } + }; + + return ( + <main className="min-h-screen bg-background"> + {/* Hero Section */} + <section className="border-b-3 border-foreground bg-secondary/30 py-16 md:py-24"> + <div className="container mx-auto px-4"> + <div className="grid gap-12 lg:grid-cols-2 items-center"> + <div className="space-y-6"> + <span className="inline-block border-3 border-foreground bg-accent px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + FOR LANDLORDS + </span> + <h1 className="font-mono text-4xl font-black leading-tight md:text-5xl lg:text-6xl text-balance"> + Get Your Full Rent{" "} + <span className="text-primary">Upfront.</span> + </h1> + <p className="text-lg text-muted-foreground md:text-xl max-w-lg leading-relaxed"> + Stop waiting for monthly payments. Partner with Shelterflex and + receive your annual rent within 48 hours of tenant move-in. + </p> + <div className="flex flex-wrap gap-4"> + <Button + onClick={() => + document + .getElementById("partner-form") + ?.scrollIntoView({ behavior: "smooth" }) + } + className="border-3 border-foreground bg-primary px-8 py-6 text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + > + Become a Partner + <ArrowRight className="ml-2 h-5 w-5" /> + </Button> + <Button + variant="outline" + onClick={() => + document + .getElementById("how-it-works") + ?.scrollIntoView({ behavior: "smooth" }) + } + className="border-3 border-foreground bg-background px-8 py-6 text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + > + How It Works + </Button> + </div> + </div> + + <div className="relative"> + <div className="border-3 border-foreground bg-card p-8 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]"> + <div className="mb-6 text-center"> + <p className="text-sm text-muted-foreground"> + Your potential earnings + </p> + <p className="font-mono text-5xl font-black text-primary"> + ₦3.6M + </p> + <p className="text-sm text-muted-foreground"> + per property annually + </p> + </div> + <div className="space-y-3"> + <div className="flex items-center gap-3 text-sm"> + <Check className="h-5 w-5 text-secondary" /> + <span>Payment in 48 hours</span> + </div> + <div className="flex items-center gap-3 text-sm"> + <Check className="h-5 w-5 text-secondary" /> + <span>No commission fees</span> + </div> + <div className="flex items-center gap-3 text-sm"> + <Check className="h-5 w-5 text-secondary" /> + <span>Verified tenants only</span> + </div> + <div className="flex items-center gap-3 text-sm"> + <Check className="h-5 w-5 text-secondary" /> + <span>Zero default risk</span> + </div> + </div> + </div> + <div className="absolute -right-4 -top-4 h-16 w-16 border-3 border-foreground bg-primary" /> + <div className="absolute -bottom-4 -left-4 h-12 w-12 border-3 border-foreground bg-accent" /> + </div> + </div> + </div> + </section> + + {/* Stats */} + <section className="border-b-3 border-foreground bg-foreground py-8"> + <div className="container mx-auto px-4"> + <div className="grid grid-cols-2 gap-8 md:grid-cols-4"> + {statsConfig.map((s) => ( + <div key={s.key} className="text-center"> + <p className="font-mono text-2xl font-black text-background md:text-3xl"> + {statsLoading ? ( + <Loader2 className="inline h-6 w-6 animate-spin" /> + ) : ( + stats[s.key] + )} + </p> + <p className="text-sm text-background/70">{s.label}</p> + </div> + ))} + </div> + </div> + </section> + + {/* Benefits */} + <section className="py-16 md:py-24"> + <div className="container mx-auto px-4"> + <div className="mb-12 text-center"> + <span className="mb-4 inline-block border-3 border-foreground bg-secondary px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + BENEFITS + </span> + <h2 className="font-mono text-3xl font-black md:text-5xl text-balance"> + Why Landlords Love Us + </h2> + </div> + + <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> + {landlordBenefits.map((benefit, i) => { + let bgClass = "bg-accent/20"; + if (i % 3 === 0) bgClass = "bg-primary/10"; + else if (i % 3 === 1) bgClass = "bg-secondary/20"; + + return ( + <div + key={benefit.title} + className={`border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] ${bgClass}`} + > + <div className="mb-4 text-foreground"> + {iconMap[benefit.title]} + </div> + <h3 className="mb-2 font-mono text-xl font-bold"> + {benefit.title} + </h3> + <p className="text-muted-foreground">{benefit.description}</p> + </div> + ); + })} + </div> + </div> + </section> + + {/* How It Works */} + <section + id="how-it-works" + className="border-y-3 border-foreground bg-muted py-16 md:py-24" + > + <div className="container mx-auto px-4"> + <div className="mb-12 text-center"> + <span className="mb-4 inline-block border-3 border-foreground bg-accent px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + HOW IT WORKS + </span> + <h2 className="font-mono text-3xl font-black md:text-5xl text-balance"> + Simple Process for Landlords + </h2> + </div> + + <div className="grid gap-6 md:grid-cols-4"> + {[ + { + step: "01", + title: "Sign Up", + desc: "Register as a partner landlord in under 5 minutes.", + }, + { + step: "02", + title: "List Property", + desc: "Add your property details and set your annual rent.", + }, + { + step: "03", + title: "We Find Tenants", + desc: "We match you with verified, creditworthy tenants.", + }, + { + step: "04", + title: "Get Paid", + desc: "Receive full annual rent within 48 hours of move-in.", + }, + ].map((item, i) => ( + <div + key={item.step} + className="border-3 border-foreground bg-card p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" + > + <span + className={`mb-4 inline-block font-mono text-5xl font-black ${i % 2 === 0 ? "text-primary" : "text-secondary"}`} + > + {item.step} + </span> + <h3 className="mb-2 font-mono text-xl font-bold"> + {item.title} + </h3> + <p className="text-muted-foreground">{item.desc}</p> + </div> + ))} + </div> + </div> + </section> + + {/* Partner Form */} + <section + id="partner-form" + className="border-t-3 border-foreground bg-primary py-16 md:py-24" + > + <div className="container mx-auto px-4"> + <div className="mx-auto max-w-2xl"> + <div className="mb-8 text-center"> + <h2 className="mb-4 font-mono text-3xl font-black text-primary-foreground md:text-4xl"> + Become a Partner Landlord + </h2> + <p className="text-primary-foreground/80"> + Fill out the form below and our team will reach out within 24 + hours. + </p> + </div> + + {partnerSuccess ? ( + <div className="border-3 border-foreground bg-background p-8 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)] text-center"> + <div className="flex justify-center mb-4"> + <div className="flex h-16 w-16 items-center justify-center border-3 border-foreground bg-secondary"> + <CheckCircle className="h-10 w-10" /> + </div> + </div> + <h3 className="font-mono text-2xl font-black mb-2"> + Application Received! + </h3> + <p className="text-muted-foreground"> + Our team will reach out to you within 24 hours to discuss your + partnership. + </p> + </div> + ) : ( + <form + onSubmit={(e) => void handlePartnerSubmit(e)} + noValidate + className="border-3 border-foreground bg-background p-8 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]" + > + <div className="grid gap-6 md:grid-cols-2"> + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Full Name + </p> + <Input + id="partner-full-name" + type="text" + placeholder="Enter your name" + value={partnerForm.fullName} + onChange={(e) => + setPartnerForm((p) => ({ ...p, fullName: e.target.value })) + } + aria-describedby={fieldErrors.fullName ? "err-name" : undefined} + className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + /> + {fieldErrors.fullName && ( + <p id="err-name" className="mt-1 text-xs font-bold text-destructive"> + {fieldErrors.fullName} + </p> + )} + </div> + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Phone Number + </p> + <Input + id="partner-phone-number" + type="tel" + placeholder="08X XXX XXXX" + value={partnerForm.phone} + onChange={(e) => + setPartnerForm((p) => ({ ...p, phone: e.target.value })) + } + aria-describedby={fieldErrors.phone ? "err-phone" : undefined} + className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + /> + {fieldErrors.phone && ( + <p id="err-phone" className="mt-1 text-xs font-bold text-destructive"> + {fieldErrors.phone} + </p> + )} + </div> + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Email Address + </p> + <Input + id="partner-email" + type="email" + placeholder="you@shelterflex.com" + value={partnerForm.email} + onChange={(e) => + setPartnerForm((p) => ({ ...p, email: e.target.value })) + } + aria-describedby={fieldErrors.email ? "err-email" : undefined} + className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + /> + {fieldErrors.email && ( + <p id="err-email" className="mt-1 text-xs font-bold text-destructive"> + {fieldErrors.email} + </p> + )} + </div> + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Number of Properties + </p> + <Input + id="partner-property-count" + type="number" + placeholder="e.g. 5" + min="1" + value={partnerForm.propertyCount} + onChange={(e) => + setPartnerForm((p) => ({ ...p, propertyCount: e.target.value })) + } + aria-describedby={fieldErrors.propertyCount ? "err-count" : undefined} + className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + /> + {fieldErrors.propertyCount && ( + <p id="err-count" className="mt-1 text-xs font-bold text-destructive"> + {fieldErrors.propertyCount} + </p> + )} + </div> + </div> + <div className="mt-6"> + <p className="mb-2 block font-mono text-sm font-bold"> + Property Location(s) + </p> + <Input + id="partner-property-locations" + type="text" + placeholder="e.g. Lekki, Lagos" + value={partnerForm.propertyLocations} + onChange={(e) => + setPartnerForm((p) => ({ ...p, propertyLocations: e.target.value })) + } + className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + /> + </div> + + {partnerError && ( + <div + role="alert" + className="mt-4 border-3 border-destructive bg-red-50 p-4 text-sm font-bold text-destructive" + > + {partnerError} + </div> + )} + + <Button + type="submit" + disabled={partnerSubmitting} + className="mt-8 w-full border-3 border-foreground bg-primary px-8 py-6 text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] disabled:opacity-60" + > + {partnerSubmitting ? ( + <> + <Loader2 className="mr-2 h-5 w-5 animate-spin" /> + Submitting… + </> + ) : ( + <> + Submit Application + <ArrowRight className="ml-2 h-5 w-5" /> + </> + )} + </Button> + </form> + )} + </div> + </div> + </section> + </main> + ); +} diff --git a/frontend/app/landlords/page.tsx b/frontend/app/landlords/page.tsx index 95054fb2b..8f4dc0118 100644 --- a/frontend/app/landlords/page.tsx +++ b/frontend/app/landlords/page.tsx @@ -1,500 +1,18 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useState, useEffect } from "react"; -import { - ArrowRight, - Check, - Shield, - Zap, - TrendingUp, - Building, - Banknote, - Loader2, - CheckCircle, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { landlordBenefits } from "@/lib/landlordBenefits"; -import { getPublicLandlordStats } from "@/lib/publicStatsApi"; -import type { LandlordPublicStats } from "@/lib/publicStatsApi"; - -const API_BASE = - process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000"; - -const iconMap: Record<string, ReactNode> = { - "Get Paid Upfront": <Banknote className="h-10 w-10" />, - "Zero Default Risk": <Shield className="h-10 w-10" />, - "Verified Tenants": <Check className="h-10 w-10" />, - "Fill Vacancies Faster": <TrendingUp className="h-10 w-10" />, - "Quick Onboarding": <Zap className="h-10 w-10" />, - "Property Management": <Building className="h-10 w-10" />, -}; - -const fallbackStats: LandlordPublicStats = { - totalPaidToLandlords: "-", - partnerLandlords: "-", - avgPaymentTime: "-", - landlordDefaultRate: "-", -}; - -const statsConfig: { key: keyof LandlordPublicStats; label: string }[] = [ - { key: "totalPaidToLandlords", label: "Paid to Landlords" }, - { key: "partnerLandlords", label: "Partner Landlords" }, - { key: "avgPaymentTime", label: "Avg. Payment Time" }, - { key: "landlordDefaultRate", label: "Default Rate" }, -]; +import type { Metadata } from "next"; +import { buildPageMetadata } from "@/lib/seo"; +import LandlordsClient from "./LandlordsClient"; + +/** + * Server entry point for `/landlords` — see the note in `app/page.tsx` on why + * the route file is not the client component. + */ +export const metadata: Metadata = buildPageMetadata({ + title: "For Landlords — Get Your Full Rent Upfront", + description: + "Partner with Shelterflex and receive your annual rent within 48 hours of tenant move-in, while your tenants pay monthly. List your property for free.", + path: "/landlords", +}); export default function LandlordsPage() { - const [partnerForm, setPartnerForm] = useState({ - fullName: "", - phone: "", - email: "", - propertyCount: "", - propertyLocations: "", - }); - const [partnerSubmitting, setPartnerSubmitting] = useState(false); - const [partnerError, setPartnerError] = useState<string | null>(null); - const [partnerSuccess, setPartnerSuccess] = useState(false); - const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); - const [stats, setStats] = useState<LandlordPublicStats>(fallbackStats); - const [statsLoading, setStatsLoading] = useState(true); - - useEffect(() => { - getPublicLandlordStats() - .then(setStats) - .catch(() => setStats(fallbackStats)) - .finally(() => setStatsLoading(false)); - }, []); - - const validatePartnerForm = () => { - const errors: Record<string, string> = {}; - if (!partnerForm.fullName.trim()) errors.fullName = "Full name is required."; - if (!partnerForm.phone.trim()) errors.phone = "Phone number is required."; - if (!partnerForm.email.trim()) errors.email = "Email is required."; - else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(partnerForm.email)) - errors.email = "Enter a valid email address."; - if (!partnerForm.propertyCount) errors.propertyCount = "Number of properties is required."; - return errors; - }; - - const handlePartnerSubmit = async (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - setPartnerError(null); - setFieldErrors({}); - - const errors = validatePartnerForm(); - if (Object.keys(errors).length > 0) { - setFieldErrors(errors); - return; - } - - setPartnerSubmitting(true); - try { - const res = await fetch(`${API_BASE}/api/landlord/partner-application`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - fullName: partnerForm.fullName, - phone: partnerForm.phone, - email: partnerForm.email, - propertyCount: parseInt(partnerForm.propertyCount, 10), - propertyLocations: partnerForm.propertyLocations, - }), - }); - - if (res.status === 404) { - setPartnerSuccess(true); - return; - } - - const data = await res.json() as { error?: { message?: string }; message?: string }; - - if (!res.ok) { - setPartnerError( - data?.error?.message || data?.message || "Submission failed. Please try again.", - ); - return; - } - - setPartnerSuccess(true); - } catch { - setPartnerError("Network error — please check your connection and try again."); - } finally { - setPartnerSubmitting(false); - } - }; - - return ( - <main className="min-h-screen bg-background"> - {/* Hero Section */} - <section className="border-b-3 border-foreground bg-secondary/30 py-16 md:py-24"> - <div className="container mx-auto px-4"> - <div className="grid gap-12 lg:grid-cols-2 items-center"> - <div className="space-y-6"> - <span className="inline-block border-3 border-foreground bg-accent px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - FOR LANDLORDS - </span> - <h1 className="font-mono text-4xl font-black leading-tight md:text-5xl lg:text-6xl text-balance"> - Get Your Full Rent{" "} - <span className="text-primary">Upfront.</span> - </h1> - <p className="text-lg text-muted-foreground md:text-xl max-w-lg leading-relaxed"> - Stop waiting for monthly payments. Partner with Shelterflex and - receive your annual rent within 48 hours of tenant move-in. - </p> - <div className="flex flex-wrap gap-4"> - <Button - onClick={() => - document - .getElementById("partner-form") - ?.scrollIntoView({ behavior: "smooth" }) - } - className="border-3 border-foreground bg-primary px-8 py-6 text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - Become a Partner - <ArrowRight className="ml-2 h-5 w-5" /> - </Button> - <Button - variant="outline" - onClick={() => - document - .getElementById("how-it-works") - ?.scrollIntoView({ behavior: "smooth" }) - } - className="border-3 border-foreground bg-background px-8 py-6 text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - How It Works - </Button> - </div> - </div> - - <div className="relative"> - <div className="border-3 border-foreground bg-card p-8 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]"> - <div className="mb-6 text-center"> - <p className="text-sm text-muted-foreground"> - Your potential earnings - </p> - <p className="font-mono text-5xl font-black text-primary"> - ₦3.6M - </p> - <p className="text-sm text-muted-foreground"> - per property annually - </p> - </div> - <div className="space-y-3"> - <div className="flex items-center gap-3 text-sm"> - <Check className="h-5 w-5 text-secondary" /> - <span>Payment in 48 hours</span> - </div> - <div className="flex items-center gap-3 text-sm"> - <Check className="h-5 w-5 text-secondary" /> - <span>No commission fees</span> - </div> - <div className="flex items-center gap-3 text-sm"> - <Check className="h-5 w-5 text-secondary" /> - <span>Verified tenants only</span> - </div> - <div className="flex items-center gap-3 text-sm"> - <Check className="h-5 w-5 text-secondary" /> - <span>Zero default risk</span> - </div> - </div> - </div> - <div className="absolute -right-4 -top-4 h-16 w-16 border-3 border-foreground bg-primary" /> - <div className="absolute -bottom-4 -left-4 h-12 w-12 border-3 border-foreground bg-accent" /> - </div> - </div> - </div> - </section> - - {/* Stats */} - <section className="border-b-3 border-foreground bg-foreground py-8"> - <div className="container mx-auto px-4"> - <div className="grid grid-cols-2 gap-8 md:grid-cols-4"> - {statsConfig.map((s) => ( - <div key={s.key} className="text-center"> - <p className="font-mono text-2xl font-black text-background md:text-3xl"> - {statsLoading ? ( - <Loader2 className="inline h-6 w-6 animate-spin" /> - ) : ( - stats[s.key] - )} - </p> - <p className="text-sm text-background/70">{s.label}</p> - </div> - ))} - </div> - </div> - </section> - - {/* Benefits */} - <section className="py-16 md:py-24"> - <div className="container mx-auto px-4"> - <div className="mb-12 text-center"> - <span className="mb-4 inline-block border-3 border-foreground bg-secondary px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - BENEFITS - </span> - <h2 className="font-mono text-3xl font-black md:text-5xl text-balance"> - Why Landlords Love Us - </h2> - </div> - - <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> - {landlordBenefits.map((benefit, i) => { - let bgClass = "bg-accent/20"; - if (i % 3 === 0) bgClass = "bg-primary/10"; - else if (i % 3 === 1) bgClass = "bg-secondary/20"; - - return ( - <div - key={benefit.title} - className={`border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] ${bgClass}`} - > - <div className="mb-4 text-foreground"> - {iconMap[benefit.title]} - </div> - <h3 className="mb-2 font-mono text-xl font-bold"> - {benefit.title} - </h3> - <p className="text-muted-foreground">{benefit.description}</p> - </div> - ); - })} - </div> - </div> - </section> - - {/* How It Works */} - <section - id="how-it-works" - className="border-y-3 border-foreground bg-muted py-16 md:py-24" - > - <div className="container mx-auto px-4"> - <div className="mb-12 text-center"> - <span className="mb-4 inline-block border-3 border-foreground bg-accent px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - HOW IT WORKS - </span> - <h2 className="font-mono text-3xl font-black md:text-5xl text-balance"> - Simple Process for Landlords - </h2> - </div> - - <div className="grid gap-6 md:grid-cols-4"> - {[ - { - step: "01", - title: "Sign Up", - desc: "Register as a partner landlord in under 5 minutes.", - }, - { - step: "02", - title: "List Property", - desc: "Add your property details and set your annual rent.", - }, - { - step: "03", - title: "We Find Tenants", - desc: "We match you with verified, creditworthy tenants.", - }, - { - step: "04", - title: "Get Paid", - desc: "Receive full annual rent within 48 hours of move-in.", - }, - ].map((item, i) => ( - <div - key={item.step} - className="border-3 border-foreground bg-card p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" - > - <span - className={`mb-4 inline-block font-mono text-5xl font-black ${i % 2 === 0 ? "text-primary" : "text-secondary"}`} - > - {item.step} - </span> - <h3 className="mb-2 font-mono text-xl font-bold"> - {item.title} - </h3> - <p className="text-muted-foreground">{item.desc}</p> - </div> - ))} - </div> - </div> - </section> - - {/* Partner Form */} - <section - id="partner-form" - className="border-t-3 border-foreground bg-primary py-16 md:py-24" - > - <div className="container mx-auto px-4"> - <div className="mx-auto max-w-2xl"> - <div className="mb-8 text-center"> - <h2 className="mb-4 font-mono text-3xl font-black text-primary-foreground md:text-4xl"> - Become a Partner Landlord - </h2> - <p className="text-primary-foreground/80"> - Fill out the form below and our team will reach out within 24 - hours. - </p> - </div> - - {partnerSuccess ? ( - <div className="border-3 border-foreground bg-background p-8 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)] text-center"> - <div className="flex justify-center mb-4"> - <div className="flex h-16 w-16 items-center justify-center border-3 border-foreground bg-secondary"> - <CheckCircle className="h-10 w-10" /> - </div> - </div> - <h3 className="font-mono text-2xl font-black mb-2"> - Application Received! - </h3> - <p className="text-muted-foreground"> - Our team will reach out to you within 24 hours to discuss your - partnership. - </p> - </div> - ) : ( - <form - onSubmit={(e) => void handlePartnerSubmit(e)} - noValidate - className="border-3 border-foreground bg-background p-8 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]" - > - <div className="grid gap-6 md:grid-cols-2"> - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Full Name - </p> - <Input - id="partner-full-name" - type="text" - placeholder="Enter your name" - value={partnerForm.fullName} - onChange={(e) => - setPartnerForm((p) => ({ ...p, fullName: e.target.value })) - } - aria-describedby={fieldErrors.fullName ? "err-name" : undefined} - className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - /> - {fieldErrors.fullName && ( - <p id="err-name" className="mt-1 text-xs font-bold text-destructive"> - {fieldErrors.fullName} - </p> - )} - </div> - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Phone Number - </p> - <Input - id="partner-phone-number" - type="tel" - placeholder="08X XXX XXXX" - value={partnerForm.phone} - onChange={(e) => - setPartnerForm((p) => ({ ...p, phone: e.target.value })) - } - aria-describedby={fieldErrors.phone ? "err-phone" : undefined} - className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - /> - {fieldErrors.phone && ( - <p id="err-phone" className="mt-1 text-xs font-bold text-destructive"> - {fieldErrors.phone} - </p> - )} - </div> - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Email Address - </p> - <Input - id="partner-email" - type="email" - placeholder="you@shelterflex.com" - value={partnerForm.email} - onChange={(e) => - setPartnerForm((p) => ({ ...p, email: e.target.value })) - } - aria-describedby={fieldErrors.email ? "err-email" : undefined} - className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - /> - {fieldErrors.email && ( - <p id="err-email" className="mt-1 text-xs font-bold text-destructive"> - {fieldErrors.email} - </p> - )} - </div> - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Number of Properties - </p> - <Input - id="partner-property-count" - type="number" - placeholder="e.g. 5" - min="1" - value={partnerForm.propertyCount} - onChange={(e) => - setPartnerForm((p) => ({ ...p, propertyCount: e.target.value })) - } - aria-describedby={fieldErrors.propertyCount ? "err-count" : undefined} - className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - /> - {fieldErrors.propertyCount && ( - <p id="err-count" className="mt-1 text-xs font-bold text-destructive"> - {fieldErrors.propertyCount} - </p> - )} - </div> - </div> - <div className="mt-6"> - <p className="mb-2 block font-mono text-sm font-bold"> - Property Location(s) - </p> - <Input - id="partner-property-locations" - type="text" - placeholder="e.g. Lekki, Lagos" - value={partnerForm.propertyLocations} - onChange={(e) => - setPartnerForm((p) => ({ ...p, propertyLocations: e.target.value })) - } - className="border-3 border-foreground py-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - /> - </div> - - {partnerError && ( - <div - role="alert" - className="mt-4 border-3 border-destructive bg-red-50 p-4 text-sm font-bold text-destructive" - > - {partnerError} - </div> - )} - - <Button - type="submit" - disabled={partnerSubmitting} - className="mt-8 w-full border-3 border-foreground bg-primary px-8 py-6 text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] disabled:opacity-60" - > - {partnerSubmitting ? ( - <> - <Loader2 className="mr-2 h-5 w-5 animate-spin" /> - Submitting… - </> - ) : ( - <> - Submit Application - <ArrowRight className="ml-2 h-5 w-5" /> - </> - )} - </Button> - </form> - )} - </div> - </div> - </section> - </main> - ); + return <LandlordsClient />; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 30fdb2e7f..eef192ab2 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -20,6 +20,13 @@ import { Geist, Geist_Mono } from 'next/font/google' import './globals.css' import { NextIntlClientProvider } from "next-intl" import { locales, defaultLocale, rtlLocales, type Locale } from "@/i18n" +import { + DEFAULT_DESCRIPTION, + DEFAULT_OG_IMAGE, + DEFAULT_TITLE, + SITE_NAME, + SITE_URL, +} from "@/lib/seo" const geistSans = Geist({ variable: '--font-geist-sans', @@ -32,14 +39,37 @@ const geistMono = Geist_Mono({ }) export const metadata: Metadata = { - title: 'Shelterflex - Rent Now, Pay Later', - description: 'The smarter way to pay your rent. Split your rent payments into affordable monthly installments.', + // metadataBase makes every relative canonical/OpenGraph URL below resolve to + // an absolute one, which is what crawlers and link unfurlers require. + metadataBase: new URL(SITE_URL), + title: { + default: DEFAULT_TITLE, + // Per-route titles read as "<page> | Shelterflex" without repeating the + // suffix in every route's metadata export. + template: `%s | ${SITE_NAME}`, + }, + description: DEFAULT_DESCRIPTION, + applicationName: SITE_NAME, manifest: '/manifest.json', icons: { icon: '/icon.svg', shortcut: '/icon.svg', apple: '/icon.svg', }, + openGraph: { + type: 'website', + siteName: SITE_NAME, + title: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, + url: SITE_URL, + images: [{ url: DEFAULT_OG_IMAGE, alt: SITE_NAME }], + }, + twitter: { + card: 'summary', + title: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, + images: [DEFAULT_OG_IMAGE], + }, } export default async function RootLayout({ diff --git a/frontend/app/messages/layout.tsx b/frontend/app/messages/layout.tsx new file mode 100644 index 000000000..c884a602c --- /dev/null +++ b/frontend/app/messages/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Private conversations between tenants and landlords — never indexable. + */ +export const metadata: Metadata = privatePageMetadata("Messages"); + +export default function MessagesLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/messages/page.tsx b/frontend/app/messages/page.tsx index 140c91add..028f7f547 100644 --- a/frontend/app/messages/page.tsx +++ b/frontend/app/messages/page.tsx @@ -25,6 +25,12 @@ import { ArrowDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + EmptyState, + ErrorState, + ListRowSkeleton, + LoadingState, +} from "@/components/ui/data-state"; import { Input } from "@/components/ui/input"; import { DropdownMenu, @@ -183,6 +189,13 @@ export default function MessagesPage() { }; }, []); + // Bumped by the error state's retry so the effect below re-runs. + const [conversationsReloadToken, setConversationsReloadToken] = useState(0); + const retryConversations = useCallback( + () => setConversationsReloadToken((token) => token + 1), + [], + ); + useEffect(() => { if (!isAuthenticated) return; @@ -206,7 +219,7 @@ export default function MessagesPage() { }; loadConversations(); - }, [isAuthenticated, debouncedSearch]); + }, [isAuthenticated, debouncedSearch, conversationsReloadToken]); const loadMoreConversations = useCallback(async () => { if (!hasMoreConversations || !conversationCursor || isLoadingConversations) return; @@ -574,17 +587,18 @@ export default function MessagesPage() { className="h-[calc(100vh-180px)] overflow-y-auto" > {isLoadingConversations && conversations.length === 0 ? ( - <div className="flex justify-center py-12"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> + <LoadingState label="Loading conversations" className="space-y-3 p-4"> + {Array.from({ length: 6 }).map((_, i) => ( + <ListRowSkeleton key={i} /> + ))} + </LoadingState> ) : conversationsError ? ( - <div className="flex flex-col items-center px-6 py-12 text-center"> - <AlertCircle className="h-8 w-8 text-destructive mb-2" /> - <p className="text-sm text-destructive">{conversationsError}</p> - <Button variant="outline" size="sm" onClick={() => window.location.reload()} className="mt-4 border-2 border-foreground"> - <RefreshCw className="mr-1 h-3 w-3" /> Retry - </Button> - </div> + <ErrorState + className="m-4" + title="Couldn't load your conversations" + description={conversationsError} + onRetry={retryConversations} + /> ) : showNoResults ? ( <div className="flex flex-col items-center justify-center px-6 py-16 text-center"> <div className="flex h-16 w-16 items-center justify-center border-3 border-foreground bg-muted"> @@ -596,15 +610,13 @@ export default function MessagesPage() { </p> </div> ) : showNoConversations ? ( - <div className="flex flex-col items-center justify-center px-6 py-16 text-center"> - <div className="flex h-16 w-16 items-center justify-center border-3 border-foreground bg-muted"> - <MessageSquareOff className="h-8 w-8 text-muted-foreground" /> - </div> - <h3 className="mt-4 font-bold">No conversations yet</h3> - <p className="mt-2 text-sm text-muted-foreground"> - Start messaging a landlord or tenant - </p> - </div> + <EmptyState + className="m-4 border-0" + icon={MessageSquareOff} + title="No conversations yet" + description="Message a landlord from any listing and the thread will show up here." + action={{ label: "Browse properties", href: "/properties" }} + /> ) : ( <> {conversations.map((conv) => { diff --git a/frontend/app/offline/layout.tsx b/frontend/app/offline/layout.tsx new file mode 100644 index 000000000..5a4f07418 --- /dev/null +++ b/frontend/app/offline/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Service-worker offline fallback; has no standalone content to index. + */ +export const metadata: Metadata = privatePageMetadata("Offline"); + +export default function OfflineLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/onboarding/layout.tsx b/frontend/app/onboarding/layout.tsx new file mode 100644 index 000000000..e2807207d --- /dev/null +++ b/frontend/app/onboarding/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Authenticated onboarding flow; carries applicant data and is not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Onboarding"); + +export default function OnboardingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index b9d454fdf..99256ed4a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,321 +1,24 @@ -"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, +import type { Metadata } from "next"; +import { DEFAULT_TITLE, buildPageMetadata } from "@/lib/seo"; +import HomeClient from "./HomeClient"; + +/** + * Server entry point for `/`. The page itself is a client component, and + * metadata exported from a `"use client"` module is invisible to crawlers and + * link unfurlers — so the route file stays a server component and owns it. + */ +export const metadata: Metadata = { + ...buildPageMetadata({ + title: DEFAULT_TITLE, + description: + "Stop stressing about annual rent payments. Shelterflex helps you split your rent into affordable monthly installments across Nigeria.", + path: "/", + }), + // `title.absolute` keeps the homepage off the "%s | Shelterflex" template, + // which would otherwise render "Shelterflex - Rent Now, Pay Later | Shelterflex". + title: { absolute: DEFAULT_TITLE }, }; export default function HomePage() { - const [stats, setStats] = useState<HomePageStats | null>(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 ( - <main> - {/* Hero Section */} - <section className="relative overflow-hidden bg-background py-12 sm:py-16 md:py-20 lg:py-24"> - <div className="container mx-auto px-4 sm:px-6"> - <div className="grid gap-8 lg:grid-cols-2 lg:gap-12 items-center"> - <div className="space-y-6 sm:space-y-8"> - <div className="inline-flex items-center gap-2 border-3 border-foreground bg-accent px-3 py-1.5 sm:px-4 sm:py-2 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <span className="font-mono text-xs sm:text-sm font-bold">NEW</span> - <span className="text-xs sm:text-sm"> - Now available in Lagos, Abuja & Port Harcourt - </span> - </div> - - <h1 className="font-mono text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black leading-tight text-balance"> - Rent Now, - <br /> - <span className="text-primary">Pay Later.</span> - </h1> - - <p className="text-base sm:text-lg md:text-xl max-w-lg leading-relaxed text-muted-foreground"> - Stop stressing about annual rent payments. Shelterflex helps you - split your rent into affordable monthly installments. - </p> - - <div className="flex flex-col gap-3 sm:flex-row sm:gap-4"> - <Link href="/properties"> - <Button className="border-3 border-foreground bg-primary px-6 py-4 sm:px-8 sm:py-6 text-base sm:text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] min-h-12 w-full sm:w-auto"> - Find a Home - <ArrowRight className="ml-2 h-4 w-4 sm:h-5 sm:w-5" /> - </Button> - </Link> - <Link href="/calculator"> - <Button - variant="outline" - className="border-3 border-foreground bg-background px-6 py-4 sm:px-8 sm:py-6 text-base sm:text-lg font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] min-h-12 w-full sm:w-auto" - > - Calculate Payments - </Button> - </Link> - </div> - - <div className="flex items-center gap-3 pt-4 sm:pt-6"> - <div className="flex -space-x-2 sm:-space-x-3"> - {[1, 2, 3, 4].map((i) => ( - <div - key={i} - className="h-8 w-8 sm:h-10 sm:w-10 rounded-full border-3 border-foreground bg-secondary" - /> - ))} - </div> - <p className="text-xs sm:text-sm text-muted-foreground"> - <span className="font-bold text-foreground">2,400+</span>{" "} - tenants joined this month - </p> - </div> - </div> - - <div className="relative"> - <div className="border-3 border-foreground bg-card p-6 shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]"> - <div className="mb-4 flex items-center justify-between"> - <span className="font-mono text-sm font-bold text-muted-foreground"> - PAYMENT PREVIEW - </span> - <span className="border-2 border-foreground bg-secondary px-2 py-1 text-xs font-bold"> - SAMPLE - </span> - </div> - <div className="space-y-4"> - <div className="border-b-2 border-dashed border-foreground/30 pb-4"> - <p className="text-sm text-muted-foreground">Annual Rent</p> - <p className="font-mono text-3xl font-black">₦2,400,000</p> - </div> - <div className="flex items-center gap-2 text-muted-foreground"> - <ChevronRight className="h-4 w-4" /> - <span>Split into 12 monthly payments</span> - </div> - <div className="border-3 border-foreground bg-primary/10 p-4"> - <p className="text-sm text-muted-foreground"> - You pay monthly - </p> - <p className="font-mono text-4xl font-black text-primary"> - ₦215,000 - </p> - <p className="text-xs text-muted-foreground mt-1"> - *excludes inspection fee + 20% deposit - </p> - </div> - </div> - </div> - - <div className="absolute -right-4 -top-4 h-16 w-16 border-3 border-foreground bg-accent" /> - <div className="absolute -bottom-4 -left-4 h-12 w-12 border-3 border-foreground bg-secondary" /> - </div> - </div> - </div> - </section> - - {/* Stats Bar */} - {homePageStats.length > 0 && ( - <section className="border-y-3 border-foreground bg-foreground py-6"> - <div className="container mx-auto px-4"> - <div className="grid grid-cols-2 gap-8 md:grid-cols-4"> - {homePageStats.map((stat) => ( - <div key={stat.label} className="text-center"> - <p className="font-mono text-2xl font-black text-background md:text-3xl"> - {stat.value} - </p> - <p className="text-sm text-background/70">{stat.label}</p> - </div> - ))} - </div> - </div> - </section> - )} - - {/* How It Works */} - <section className="bg-muted py-16 md:py-24"> - <div className="container mx-auto px-4"> - <div className="mb-12 text-center"> - <span className="mb-4 inline-block border-3 border-foreground bg-accent px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - HOW IT WORKS - </span> - <h2 className="font-mono text-3xl font-black md:text-5xl text-balance"> - Get Your Dream Home in 4 Simple Steps - </h2> - </div> - - <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> - {[ - { - step: "01", - title: "Browse Properties", - desc: "Explore verified rental listings in your preferred location.", - }, - { - step: "02", - title: "Apply Online", - desc: "Submit your application with basic documents in minutes.", - }, - { - step: "03", - title: "Get Approved", - desc: "Receive approval within 24 hours of application.", - }, - { - step: "04", - title: "Move In", - desc: "Pay your first installment and get your keys.", - }, - ].map((item, i) => { - let stepColorClass = "text-secondary"; - if (i % 2 === 0) stepColorClass = "text-primary"; - - return ( - <div - key={item.step} - className="group border-3 border-foreground bg-card p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - <span - className={`mb-4 inline-block font-mono text-5xl font-black ${stepColorClass}`} - > - {item.step} - </span> - <h3 className="mb-2 font-mono text-xl font-bold"> - {item.title} - </h3> - <p className="text-muted-foreground">{item.desc}</p> - </div> - ); - })} - </div> - </div> - </section> - - {/* Benefits Section */} - <section className="py-16 md:py-24"> - <div className="container mx-auto px-4"> - <div className="grid gap-12 lg:grid-cols-2 items-center"> - <div> - <span className="mb-4 inline-block border-3 border-foreground bg-secondary px-4 py-2 font-mono text-sm font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - WHY SHELTERFLEX - </span> - <h2 className="mb-6 font-mono text-3xl font-black md:text-5xl text-balance"> - Renting Made <span className="text-primary">Stress-Free</span> - </h2> - <p className="mb-8 text-lg text-muted-foreground leading-relaxed"> - We understand that coming up with a full year rent upfront is - challenging. That is why we created a solution that works for - everyone. - </p> - - <div className="space-y-4"> - {[ - "No collateral required", - "Flexible payment terms", - "Build your credit score", - "24/7 customer support", - ].map((item) => ( - <div key={item} className="flex items-center gap-3"> - <div className="flex h-8 w-8 items-center justify-center border-3 border-foreground bg-secondary"> - <Check className="h-4 w-4" /> - </div> - <span className="font-medium">{item}</span> - </div> - ))} - </div> - - <div className="mt-8"> - <Link href="/about"> - <Button className="border-3 border-foreground bg-primary px-6 py-4 font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> - Learn More About Us - <ArrowRight className="ml-2 h-4 w-4" /> - </Button> - </Link> - </div> - </div> - - <div className="grid gap-4 sm:grid-cols-2"> - {homePageBenefits.map((benefit, i) => { - const iconKey = Object.keys(iconMap)[ - i % 4 - ] as keyof typeof iconMap; - const Icon = iconMap[iconKey]; - let bgClass = "bg-card"; - if (i === 0) bgClass = "bg-primary/10"; - else if (i === 1) bgClass = "bg-secondary/30"; - else if (i === 2) bgClass = "bg-accent/30"; - return ( - <div - key={benefit.title} - className={`border-3 border-foreground p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] ${bgClass}`} - > - <Icon className="mb-4 h-8 w-8" /> - <h3 className="mb-2 font-mono text-lg font-bold"> - {benefit.title} - </h3> - <p className="text-sm text-muted-foreground"> - {benefit.description} - </p> - </div> - ); - })} - </div> - </div> - </div> - </section> - - {/* CTA Section */} - <section className="border-y-3 border-foreground bg-primary py-16 md:py-24"> - <div className="container mx-auto px-4 text-center"> - <h2 className="mb-6 font-mono text-3xl font-black text-primary-foreground md:text-5xl text-balance"> - Ready to Find Your New Home? - </h2> - <p className="mb-8 text-lg text-primary-foreground/80 max-w-2xl mx-auto leading-relaxed"> - Join thousands of Nigerians who have made the smart choice. Start - your journey to stress-free renting today. - </p> - <div className="flex flex-wrap justify-center gap-4"> - <Link href="/signup"> - <Button className="border-3 border-foreground bg-background px-8 py-6 text-lg font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> - Get Started Free - <ArrowRight className="ml-2 h-5 w-5" /> - </Button> - </Link> - <Link href="/landlords"> - <Button - variant="outline" - className="border-3 border-foreground bg-transparent px-8 py-6 text-lg font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)] hover:bg-background/10" - > - I am a Landlord - </Button> - </Link> - </div> - </div> - </section> - </main> - ); + return <HomeClient />; } diff --git a/frontend/app/pre-screen/layout.tsx b/frontend/app/pre-screen/layout.tsx new file mode 100644 index 000000000..d143b7045 --- /dev/null +++ b/frontend/app/pre-screen/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Applicant pre-screening carries personal financial input — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Pre-Screening"); + +export default function PreScreenLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/privacy/page.tsx b/frontend/app/privacy/page.tsx index d8cf9dd3a..3c7b855ea 100644 --- a/frontend/app/privacy/page.tsx +++ b/frontend/app/privacy/page.tsx @@ -13,7 +13,7 @@ export const dynamic = "force-static"; * cannot deliver a user to the placeholder version. */ export const metadata: Metadata = { - title: "Privacy Policy — Shelterflex", + title: "Privacy Policy", description: "Learn how Shelterflex collects, uses, and protects your personal data. Official legal copy will be updated before launch.", alternates: { canonical: "/privacy-policy" }, diff --git a/frontend/app/properties/PropertiesClient.tsx b/frontend/app/properties/PropertiesClient.tsx new file mode 100644 index 000000000..e2ddafe43 --- /dev/null +++ b/frontend/app/properties/PropertiesClient.tsx @@ -0,0 +1,654 @@ +"use client"; + +import { useState, useEffect, useCallback, Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Search, SlidersHorizontal, SearchX, X, Map, Scale } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + PropertyCard, + propertyListingToCard, +} from "@/components/property-card"; +import { PropertyCardSkeleton } from "@/components/property-card-skeleton"; +import { EmptyState, LoadingState } from "@/components/ui/data-state"; +import useAuthStore from "@/store/useAuthStore"; +import { + fetchSavedListingIds, + setListingSaved, +} from "@/lib/savedPropertiesApi"; +import { + searchProperties, + type PropertySearchFilters, + type PropertyListing, +} from "@/lib/propertiesApi"; +import { + parseCompareIds, + canCompare, + MIN_COMPARE, +} from "@/lib/compare"; + +const CITIES = ["Lagos", "Abuja", "Port Harcourt", "Ibadan", "Enugu"]; +const BED_OPTIONS = ["Any", "1", "2", "3", "4", "4+"]; +const BATH_OPTIONS = ["Any", "1", "2", "3", "3+"]; +const SORT_OPTIONS = [ + { value: "newest", label: "Newest" }, + { value: "price_asc", label: "Price: Low to High" }, + { value: "price_desc", label: "Price: High to Low" }, + { value: "bedrooms_desc", label: "Most Bedrooms" }, +]; + +function PropertiesContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [savedListingIds, setSavedListingIds] = useState<string[]>([]); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const [showFilters, setShowFilters] = useState(false); + const [properties, setProperties] = useState<PropertyListing[]>([]); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState( + searchParams.get("query") || "", + ); + + const compareIds = parseCompareIds(searchParams.get("ids")); + const canCompareProperties = canCompare(compareIds); + + useEffect(() => { + if (typeof window === 'undefined') return; + const saved = sessionStorage.getItem('properties_scroll_y'); + if (saved) { + const y = parseInt(saved, 10); + if (!isNaN(y)) { + requestAnimationFrame(() => window.scrollTo(0, y)); + } + sessionStorage.removeItem('properties_scroll_y'); + } + }, []); + + useEffect(() => { + return () => { + if (typeof window !== 'undefined') { + sessionStorage.setItem('properties_scroll_y', String(window.scrollY)); + } + }; + }, []); + + // Filter state from URL + const VALID_SORT = ["newest", "price_asc", "price_desc", "bedrooms_desc"]; + const rawSort = searchParams.get("sortBy") || ""; + const sortBy = VALID_SORT.includes(rawSort) ? rawSort : "newest"; + const rawPage = parseInt(searchParams.get("page") || "", 10); + const page = !isNaN(rawPage) && rawPage > 0 ? rawPage : 1; + const city = searchParams.get("city") || ""; + const area = searchParams.get("area") || ""; + const minBedrooms = searchParams.get("minBedrooms") || ""; + const maxBedrooms = searchParams.get("maxBedrooms") || ""; + const minBathrooms = searchParams.get("minBathrooms") || ""; + const maxBathrooms = searchParams.get("maxBathrooms") || ""; + const minAnnualRent = searchParams.get("minAnnualRent") || ""; + const maxAnnualRent = searchParams.get("maxAnnualRent") || ""; + + const updateParams = (updates: Record<string, string>) => { + const newParams = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([key, value]) => { + if (!value || value === "Any" || value === "newest") { + newParams.delete(key); + } else { + newParams.set(key, value); + } + }); + // Reset to page 1 when filters change + if (!updates.page) { + newParams.delete("page"); + } + router.push(`/properties?${newParams.toString()}`); + }; + + const clearAllFilters = () => { + setSearchQuery(""); + router.push("/properties"); + }; + + const hasActiveFilters = + city || + area || + minBedrooms || + maxBedrooms || + minBathrooms || + maxBathrooms || + minAnnualRent || + maxAnnualRent; + + const fetchProperties = useCallback(async () => { + setIsLoading(true); + try { + const filters: PropertySearchFilters = { + sortBy: (sortBy as PropertySearchFilters["sortBy"]) || "newest", + page, + pageSize: 20, + }; + + if (searchQuery.trim()) filters.query = searchQuery.trim(); + if (city) filters.city = city; + if (area) filters.area = area; + if (minBedrooms && minBedrooms !== "Any") + filters.minBedrooms = parseInt(minBedrooms, 10); + if (maxBedrooms && maxBedrooms !== "Any" && maxBedrooms !== "4+") + filters.maxBedrooms = parseInt(maxBedrooms, 10); + if (maxBedrooms === "4+") filters.minBedrooms = 4; + if (minBathrooms && minBathrooms !== "Any") + filters.minBathrooms = parseInt(minBathrooms, 10); + if (maxBathrooms && maxBathrooms !== "Any" && maxBathrooms !== "3+") + filters.maxBathrooms = parseInt(maxBathrooms, 10); + if (maxBathrooms === "3+") filters.minBathrooms = 3; + if (minAnnualRent) filters.minAnnualRent = parseInt(minAnnualRent, 10); + if (maxAnnualRent) filters.maxAnnualRent = parseInt(maxAnnualRent, 10); + + const result = await searchProperties(filters); + setProperties(result.data); + setTotal(result.total); + setTotalPages(result.totalPages); + } catch (error) { + console.error("Failed to fetch properties:", error); + setProperties([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, [ + searchQuery, + city, + area, + minBedrooms, + maxBedrooms, + minBathrooms, + maxBathrooms, + minAnnualRent, + maxAnnualRent, + sortBy, + page, + ]); + + useEffect(() => { + const debounce = setTimeout(fetchProperties, 300); + return () => clearTimeout(debounce); + }, [fetchProperties]); + + useEffect(() => { + if (!isAuthenticated) { + setSavedListingIds([]); + return; + } + + let cancelled = false; + fetchSavedListingIds() + .then((ids) => { + if (!cancelled) { + setSavedListingIds(ids); + } + }) + .catch(() => { + if (!cancelled) { + setSavedListingIds([]); + } + }); + + return () => { + cancelled = true; + }; + }, [isAuthenticated]); + + const handleFavoriteChange = async (listingId: string, saved: boolean) => { + await setListingSaved(listingId, saved); + setSavedListingIds((prev) => + saved + ? prev.includes(listingId) + ? prev + : [...prev, listingId] + : prev.filter((id) => id !== listingId), + ); + }; + + const formatPrice = (price: number) => { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + }).format(price); + }; + + return ( + <main className="min-h-screen bg-background"> + {/* Hero Header */} + <section className="border-b-3 border-foreground bg-muted py-12 md:py-16"> + <div className="container mx-auto px-4"> + <h1 className="mb-4 font-mono text-3xl font-black md:text-5xl"> + Find Your <span className="text-primary">Perfect Home</span> + </h1> + <p className="text-lg text-muted-foreground max-w-2xl"> + Browse through our collection of verified rental properties. All + listings come with our rent-now-pay-later option. + </p> + </div> + </section> + + {/* Search & Filters */} + <section className="border-b-3 border-foreground bg-card py-6"> + <div className="container mx-auto px-4"> + <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> + <div className="relative flex-1 max-w-xl"> + <Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" /> + <Input + type="text" + placeholder="Search by location or property name..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + updateParams({ query: searchQuery }); + } + }} + className="border-3 border-foreground bg-background pl-12 py-6 font-medium shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + /> + </div> + + <div className="flex gap-2"> + <Button + onClick={() => updateParams({ query: searchQuery })} + className="border-3 border-foreground bg-primary px-6 py-6 font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + > + Search + </Button> + <Link href={`/properties/map?${searchParams.toString()}`}> + <Button className="border-3 border-foreground bg-background px-6 py-6 font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> + <Map className="mr-2 h-5 w-5" /> + Map View + </Button> + </Link> + {canCompareProperties && ( + <Link href={`/properties/compare?${searchParams.toString()}`}> + <Button className="border-3 border-foreground bg-background px-6 py-6 font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> + <Scale className="mr-2 h-5 w-5" /> + Compare ({compareIds.length}) + </Button> + </Link> + )} + <Button + onClick={() => setShowFilters(!showFilters)} + className="border-3 border-foreground bg-background px-6 py-6 font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + > + <SlidersHorizontal className="mr-2 h-5 w-5" /> + Filters + {hasActiveFilters && ( + <span className="ml-2 flex h-6 w-6 items-center justify-center bg-primary text-xs font-bold"> + ! + </span> + )} + </Button> + </div> + </div> + + {/* Filter Panel */} + {showFilters && ( + <div className="mt-6 border-3 border-foreground bg-background p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> + <div className="flex items-center justify-between mb-4"> + <h3 className="font-mono text-lg font-bold"> + Filter Properties + </h3> + <Button + variant="ghost" + size="sm" + onClick={clearAllFilters} + className="text-sm underline" + > + Clear All + </Button> + </div> + + <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> + {/* City */} + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + City + </p> + <div className="flex flex-wrap gap-2"> + {CITIES.map((c) => ( + <button + key={c} + onClick={() => + updateParams({ city: city === c ? "" : c }) + } + className={`border-2 border-foreground px-3 py-2 text-sm font-medium transition-all ${ + city === c + ? "bg-foreground text-background" + : "bg-background hover:bg-muted" + }`} + > + {c} + </button> + ))} + </div> + </div> + + {/* Area */} + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Neighbourhood + </p> + <Input + type="text" + placeholder="e.g. Lekki, Victoria Island" + defaultValue={area} + onChange={(e) => { + const val = e.target.value; + const timeout = setTimeout(() => updateParams({ area: val }), 400); + (e.target as HTMLInputElement).dataset.debounce = String(timeout); + }} + onBlur={(e) => { + const timeout = parseInt(e.target.dataset.debounce || "0", 10); + if (timeout) clearTimeout(timeout); + updateParams({ area: e.target.value }); + }} + className="border-2 border-foreground bg-background" + /> + </div> + + {/* Bedrooms */} + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Bedrooms + </p> + <div className="flex flex-wrap gap-2"> + {BED_OPTIONS.map((beds) => ( + <button + key={beds} + onClick={() => + updateParams({ + minBedrooms: + beds === "Any" + ? "" + : beds === "4+" + ? "4" + : beds, + maxBedrooms: + beds === "Any" + ? "" + : beds === "4+" + ? "" + : beds, + }) + } + className={`border-2 border-foreground px-4 py-2 text-sm font-medium transition-all ${ + (beds === "Any" && !minBedrooms && !maxBedrooms) || + (beds === "4+" && minBedrooms === "4" && !maxBedrooms) || + (beds !== "Any" && + beds !== "4+" && + minBedrooms === beds && + maxBedrooms === beds) + ? "bg-foreground text-background" + : "bg-background hover:bg-muted" + }`} + > + {beds} + </button> + ))} + </div> + </div> + + {/* Bathrooms */} + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Bathrooms + </p> + <div className="flex flex-wrap gap-2"> + {BATH_OPTIONS.map((baths) => ( + <button + key={baths} + onClick={() => + updateParams({ + minBathrooms: + baths === "Any" + ? "" + : baths === "3+" + ? "3" + : baths, + maxBathrooms: + baths === "Any" + ? "" + : baths === "3+" + ? "" + : baths, + }) + } + className={`border-2 border-foreground px-4 py-2 text-sm font-medium transition-all ${ + (baths === "Any" && !minBathrooms && !maxBathrooms) || + (baths === "3+" && minBathrooms === "3" && !maxBathrooms) || + (baths !== "Any" && + baths !== "3+" && + minBathrooms === baths && + maxBathrooms === baths) + ? "bg-foreground text-background" + : "bg-background hover:bg-muted" + }`} + > + {baths} + </button> + ))} + </div> + </div> + + {/* Price Range */} + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Annual Rent Range + </p> + <div className="flex gap-2"> + <Input + type="number" + placeholder="Min" + value={minAnnualRent} + onChange={(e) => + updateParams({ minAnnualRent: e.target.value }) + } + className="border-2 border-foreground bg-background" + /> + <span className="flex items-center">-</span> + <Input + type="number" + placeholder="Max" + value={maxAnnualRent} + onChange={(e) => + updateParams({ maxAnnualRent: e.target.value }) + } + className="border-2 border-foreground bg-background" + /> + </div> + </div> + + {/* Sort */} + <div> + <p className="mb-2 block font-mono text-sm font-bold"> + Sort By + </p> + <div className="flex flex-wrap gap-2"> + {SORT_OPTIONS.map((option) => ( + <button + key={option.value} + onClick={() => updateParams({ sortBy: option.value })} + className={`border-2 border-foreground px-3 py-2 text-sm font-medium transition-all ${ + sortBy === option.value + ? "bg-foreground text-background" + : "bg-background hover:bg-muted" + }`} + > + {option.label} + </button> + ))} + </div> + </div> + </div> + </div> + )} + + {/* Active Filters Display */} + {hasActiveFilters && ( + <div className="mt-4 flex flex-wrap items-center gap-2"> + <span className="text-sm text-muted-foreground">Active:</span> + {city && ( + <Badge + variant="secondary" + className="cursor-pointer border-2 border-foreground" + onClick={() => updateParams({ city: "" })} + > + {city} <X className="ml-1 h-3 w-3" /> + </Badge> + )} + {area && ( + <Badge + variant="secondary" + className="cursor-pointer border-2 border-foreground" + onClick={() => updateParams({ area: "" })} + > + {area} <X className="ml-1 h-3 w-3" /> + </Badge> + )} + {(minBedrooms || maxBedrooms) && ( + <Badge + variant="secondary" + className="cursor-pointer border-2 border-foreground" + onClick={() => + updateParams({ minBedrooms: "", maxBedrooms: "" }) + } + > + {minBedrooms || "0"}-{maxBedrooms || "∞"} bed{" "} + <X className="ml-1 h-3 w-3" /> + </Badge> + )} + {(minAnnualRent || maxAnnualRent) && ( + <Badge + variant="secondary" + className="cursor-pointer border-2 border-foreground" + onClick={() => + updateParams({ + minAnnualRent: "", + maxAnnualRent: "", + }) + } + > + {minAnnualRent + ? formatPrice(parseInt(minAnnualRent, 10)) + : "₦0"}{" "} + -{" "} + {maxAnnualRent + ? formatPrice(parseInt(maxAnnualRent, 10)) + : "∞"}{" "} + <X className="ml-1 h-3 w-3" /> + </Badge> + )} + <Button + variant="ghost" + size="sm" + onClick={clearAllFilters} + className="text-xs underline" + > + Clear all + </Button> + </div> + )} + </div> + </section> + + {/* Properties Grid */} + <section className="py-12"> + <div className="container mx-auto px-4"> + <div className="mb-6 flex items-center justify-between"> + <p className="text-muted-foreground"> + Showing{" "} + <span className="font-bold text-foreground">{total}</span>{" "} + properties + </p> + </div> + + {isLoading ? ( + <LoadingState + label="Loading properties" + className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" + > + {Array.from({ length: 8 }).map((_, i) => ( + <PropertyCardSkeleton key={i} /> + ))} + </LoadingState> + ) : properties.length === 0 ? ( + <EmptyState + icon={SearchX} + title="No properties found" + description="Nothing matches this search yet. Clearing your filters widens the search across every city we cover." + action={{ label: "Clear filters", onClick: clearAllFilters }} + /> + ) : ( + <> + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> + {properties.map((property) => ( + <PropertyCard + key={property.listingId} + property={propertyListingToCard(property)} + isFavorited={savedListingIds.includes(property.listingId)} + onFavoriteChange={(saved) => + handleFavoriteChange(property.listingId, saved) + } + showCompare={true} + /> + ))} + </div> + + {/* Pagination */} + {totalPages > 1 && ( + <div className="mt-8 flex items-center justify-center gap-2"> + <Button + variant="outline" + disabled={page <= 1} + onClick={() => updateParams({ page: String(page - 1) })} + className="border-2 border-foreground font-bold" + > + Previous + </Button> + <span className="px-4 font-mono font-bold"> + Page {page} of {totalPages} + </span> + <Button + variant="outline" + disabled={page >= totalPages} + onClick={() => updateParams({ page: String(page + 1) })} + className="border-2 border-foreground font-bold" + > + Next + </Button> + </div> + )} + </> + )} + </div> + </section> + </main> + ); +} + +export default function PropertiesClient() { + return ( + <Suspense + fallback={ + <div className="min-h-screen bg-background flex items-center justify-center"> + <p className="font-mono font-bold text-muted-foreground"> + Loading properties... + </p> + </div> + } + > + <PropertiesContent /> + </Suspense> + ); +} diff --git a/frontend/app/properties/[id]/page.tsx b/frontend/app/properties/[id]/page.tsx index ba4a10038..b7aa7ec47 100644 --- a/frontend/app/properties/[id]/page.tsx +++ b/frontend/app/properties/[id]/page.tsx @@ -1,5 +1,11 @@ import type { Metadata } from "next"; -import { getProperty } from "@/lib/propertiesApi"; +import { getProperty, type PropertyListing } from "@/lib/propertiesApi"; +import { + DEFAULT_OG_IMAGE, + SITE_NAME, + absoluteUrl, + buildPageMetadata, +} from "@/lib/seo"; import PropertyDetailClient from "./PropertyDetailClient"; type PropertyPageProps = { @@ -8,46 +14,157 @@ type PropertyPageProps = { }>; }; -const defaultTitle = "Property Details | ShelterFlex"; +const defaultTitle = "Property Details"; const defaultDescription = "Explore verified property details, amenities, and neighborhood context on ShelterFlex."; -export async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> { +function formatNgn(amount: number): string { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +function locationLabel(listing: PropertyListing): string { + return [listing.area, listing.city].filter(Boolean).join(", "); +} + +/** + * The share preview is the whole point of this route's metadata: a listing sent + * over WhatsApp should show the property photo, the rent, and the location, not + * a bare URL under the site-wide title. + */ +function listingDescription(listing: PropertyListing): string { + const location = locationLabel(listing); + const facts = [ + `${listing.bedrooms} bed`, + `${listing.bathrooms} bath`, + Number.isFinite(listing.annualRentNgn) + ? `${formatNgn(listing.annualRentNgn)}/year` + : null, + ] + .filter(Boolean) + .join(" · "); + + const summary = listing.description?.trim(); + if (summary) { + // Keep descriptions inside the ~160 characters unfurlers and SERPs show. + return summary.length > 160 ? `${summary.slice(0, 157).trimEnd()}…` : summary; + } + + return `${facts}${location ? ` in ${location}` : ""}. Rent now, pay later with ${SITE_NAME}.`; +} + +export async function generateMetadata({ + params, +}: PropertyPageProps): Promise<Metadata> { const { id } = await params; + const path = `/properties/${id}`; try { const result = await getProperty(id); const listing = result.data; - const title = `${listing.address} | ShelterFlex`; - const locationParts = [listing.city, listing.area].filter(Boolean).join(", "); - const description = listing.description - || `Discover this property in ${locationParts || "Nigeria"}, including ${listing.bedrooms} bedrooms, ${listing.bathrooms} bathrooms, and pricing details.`; + const location = locationLabel(listing); + const title = location ? `${listing.address}, ${location}` : listing.address; + const description = listingDescription(listing); - return { + // OpenGraph images must be absolute; the listing's own photo is what makes + // a shared link convert, so fall back to the site icon only if there is none. + const photo = listing.photos?.find((url) => Boolean(url?.trim())); + + return buildPageMetadata({ title, description, - openGraph: { - title, - description, - type: "website", - }, - twitter: { - card: "summary", - title, - description, - }, - }; + path, + images: [ + { + url: photo ? absoluteUrl(photo) : DEFAULT_OG_IMAGE, + ...(photo ? { width: 1200, height: 630 } : {}), + alt: `${listing.address}${location ? ` in ${location}` : ""}`, + }, + ], + }); } catch { - return { + // A listing that can't be fetched still gets a canonical URL, so a shared + // link doesn't compete with the site-wide default for its own address. + return buildPageMetadata({ title: defaultTitle, description: defaultDescription, - }; + path, + }); } } +/** + * Schema.org markup for the listing. + * + * Judged worthwhile: rental listings are exactly the content type search + * engines surface with rich results, the data is already fetched server-side + * for the metadata above, and every field maps onto an existing property on the + * listing record — no invented values. Emitted only when the fetch succeeds. + */ +function listingJsonLd(id: string, listing: PropertyListing) { + const location = locationLabel(listing); + + return { + "@context": "https://schema.org", + "@type": "Residence", + name: listing.address, + description: listingDescription(listing), + url: absoluteUrl(`/properties/${id}`), + ...(listing.photos?.length + ? { image: listing.photos.map((photo) => absoluteUrl(photo)) } + : {}), + address: { + "@type": "PostalAddress", + streetAddress: listing.address, + ...(listing.area ? { addressLocality: listing.area } : {}), + ...(listing.city ? { addressRegion: listing.city } : {}), + addressCountry: "NG", + }, + numberOfBedrooms: listing.bedrooms, + numberOfBathroomsTotal: listing.bathrooms, + ...(Number.isFinite(listing.annualRentNgn) + ? { + offers: { + "@type": "Offer", + price: listing.annualRentNgn, + priceCurrency: "NGN", + availability: "https://schema.org/InStock", + url: absoluteUrl(`/properties/${id}`), + }, + } + : {}), + ...(location ? { areaServed: location } : {}), + }; +} + export default async function PropertyDetailPage({ params }: PropertyPageProps) { const { id } = await params; - return <PropertyDetailClient propertyId={id} />; + let jsonLd: ReturnType<typeof listingJsonLd> | null = null; + try { + const result = await getProperty(id); + jsonLd = listingJsonLd(id, result.data); + } catch { + // Structured data is additive — a failed fetch just omits it. The client + // component below renders its own error state for the visible page. + jsonLd = null; + } + + return ( + <> + {jsonLd && ( + <script + type="application/ld+json" + // Serialised server-side from our own API response, not user input. + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + /> + )} + <PropertyDetailClient propertyId={id} /> + </> + ); } diff --git a/frontend/app/properties/page.tsx b/frontend/app/properties/page.tsx index 4ce29ac42..c66550653 100644 --- a/frontend/app/properties/page.tsx +++ b/frontend/app/properties/page.tsx @@ -1,661 +1,24 @@ -"use client"; +import type { Metadata } from "next"; +import { buildPageMetadata } from "@/lib/seo"; +import PropertiesClient from "./PropertiesClient"; export const dynamic = "force-dynamic"; -import { useState, useEffect, useCallback, Suspense } from "react"; -import Link from "next/link"; -import { useSearchParams, useRouter } from "next/navigation"; -import { Search, SlidersHorizontal, SearchX, X, Map, Scale } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { - PropertyCard, - propertyListingToCard, -} from "@/components/property-card"; -import { PropertyCardSkeleton } from "@/components/property-card-skeleton"; -import useAuthStore from "@/store/useAuthStore"; -import { - fetchSavedListingIds, - setListingSaved, -} from "@/lib/savedPropertiesApi"; -import { - searchProperties, - type PropertySearchFilters, - type PropertyListing, -} from "@/lib/propertiesApi"; -import { - parseCompareIds, - canCompare, - MIN_COMPARE, -} from "@/lib/compare"; - -const CITIES = ["Lagos", "Abuja", "Port Harcourt", "Ibadan", "Enugu"]; -const BED_OPTIONS = ["Any", "1", "2", "3", "4", "4+"]; -const BATH_OPTIONS = ["Any", "1", "2", "3", "3+"]; -const SORT_OPTIONS = [ - { value: "newest", label: "Newest" }, - { value: "price_asc", label: "Price: Low to High" }, - { value: "price_desc", label: "Price: High to Low" }, - { value: "bedrooms_desc", label: "Most Bedrooms" }, -]; - -function PropertiesContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - - const [savedListingIds, setSavedListingIds] = useState<string[]>([]); - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const [showFilters, setShowFilters] = useState(false); - const [properties, setProperties] = useState<PropertyListing[]>([]); - const [total, setTotal] = useState(0); - const [totalPages, setTotalPages] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState( - searchParams.get("query") || "", - ); - - const compareIds = parseCompareIds(searchParams.get("ids")); - const canCompareProperties = canCompare(compareIds); - - useEffect(() => { - if (typeof window === 'undefined') return; - const saved = sessionStorage.getItem('properties_scroll_y'); - if (saved) { - const y = parseInt(saved, 10); - if (!isNaN(y)) { - requestAnimationFrame(() => window.scrollTo(0, y)); - } - sessionStorage.removeItem('properties_scroll_y'); - } - }, []); - - useEffect(() => { - return () => { - if (typeof window !== 'undefined') { - sessionStorage.setItem('properties_scroll_y', String(window.scrollY)); - } - }; - }, []); - - // Filter state from URL - const VALID_SORT = ["newest", "price_asc", "price_desc", "bedrooms_desc"]; - const rawSort = searchParams.get("sortBy") || ""; - const sortBy = VALID_SORT.includes(rawSort) ? rawSort : "newest"; - const rawPage = parseInt(searchParams.get("page") || "", 10); - const page = !isNaN(rawPage) && rawPage > 0 ? rawPage : 1; - const city = searchParams.get("city") || ""; - const area = searchParams.get("area") || ""; - const minBedrooms = searchParams.get("minBedrooms") || ""; - const maxBedrooms = searchParams.get("maxBedrooms") || ""; - const minBathrooms = searchParams.get("minBathrooms") || ""; - const maxBathrooms = searchParams.get("maxBathrooms") || ""; - const minAnnualRent = searchParams.get("minAnnualRent") || ""; - const maxAnnualRent = searchParams.get("maxAnnualRent") || ""; - - const updateParams = (updates: Record<string, string>) => { - const newParams = new URLSearchParams(searchParams.toString()); - Object.entries(updates).forEach(([key, value]) => { - if (!value || value === "Any" || value === "newest") { - newParams.delete(key); - } else { - newParams.set(key, value); - } - }); - // Reset to page 1 when filters change - if (!updates.page) { - newParams.delete("page"); - } - router.push(`/properties?${newParams.toString()}`); - }; - - const clearAllFilters = () => { - setSearchQuery(""); - router.push("/properties"); - }; - - const hasActiveFilters = - city || - area || - minBedrooms || - maxBedrooms || - minBathrooms || - maxBathrooms || - minAnnualRent || - maxAnnualRent; - - const fetchProperties = useCallback(async () => { - setIsLoading(true); - try { - const filters: PropertySearchFilters = { - sortBy: (sortBy as PropertySearchFilters["sortBy"]) || "newest", - page, - pageSize: 20, - }; - - if (searchQuery.trim()) filters.query = searchQuery.trim(); - if (city) filters.city = city; - if (area) filters.area = area; - if (minBedrooms && minBedrooms !== "Any") - filters.minBedrooms = parseInt(minBedrooms, 10); - if (maxBedrooms && maxBedrooms !== "Any" && maxBedrooms !== "4+") - filters.maxBedrooms = parseInt(maxBedrooms, 10); - if (maxBedrooms === "4+") filters.minBedrooms = 4; - if (minBathrooms && minBathrooms !== "Any") - filters.minBathrooms = parseInt(minBathrooms, 10); - if (maxBathrooms && maxBathrooms !== "Any" && maxBathrooms !== "3+") - filters.maxBathrooms = parseInt(maxBathrooms, 10); - if (maxBathrooms === "3+") filters.minBathrooms = 3; - if (minAnnualRent) filters.minAnnualRent = parseInt(minAnnualRent, 10); - if (maxAnnualRent) filters.maxAnnualRent = parseInt(maxAnnualRent, 10); - - const result = await searchProperties(filters); - setProperties(result.data); - setTotal(result.total); - setTotalPages(result.totalPages); - } catch (error) { - console.error("Failed to fetch properties:", error); - setProperties([]); - setTotal(0); - } finally { - setIsLoading(false); - } - }, [ - searchQuery, - city, - area, - minBedrooms, - maxBedrooms, - minBathrooms, - maxBathrooms, - minAnnualRent, - maxAnnualRent, - sortBy, - page, - ]); - - useEffect(() => { - const debounce = setTimeout(fetchProperties, 300); - return () => clearTimeout(debounce); - }, [fetchProperties]); - - useEffect(() => { - if (!isAuthenticated) { - setSavedListingIds([]); - return; - } - - let cancelled = false; - fetchSavedListingIds() - .then((ids) => { - if (!cancelled) { - setSavedListingIds(ids); - } - }) - .catch(() => { - if (!cancelled) { - setSavedListingIds([]); - } - }); - - return () => { - cancelled = true; - }; - }, [isAuthenticated]); - - const handleFavoriteChange = async (listingId: string, saved: boolean) => { - await setListingSaved(listingId, saved); - setSavedListingIds((prev) => - saved - ? prev.includes(listingId) - ? prev - : [...prev, listingId] - : prev.filter((id) => id !== listingId), - ); - }; - - const formatPrice = (price: number) => { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 0, - }).format(price); - }; - - return ( - <main className="min-h-screen bg-background"> - {/* Hero Header */} - <section className="border-b-3 border-foreground bg-muted py-12 md:py-16"> - <div className="container mx-auto px-4"> - <h1 className="mb-4 font-mono text-3xl font-black md:text-5xl"> - Find Your <span className="text-primary">Perfect Home</span> - </h1> - <p className="text-lg text-muted-foreground max-w-2xl"> - Browse through our collection of verified rental properties. All - listings come with our rent-now-pay-later option. - </p> - </div> - </section> - - {/* Search & Filters */} - <section className="border-b-3 border-foreground bg-card py-6"> - <div className="container mx-auto px-4"> - <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> - <div className="relative flex-1 max-w-xl"> - <Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" /> - <Input - type="text" - placeholder="Search by location or property name..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - updateParams({ query: searchQuery }); - } - }} - className="border-3 border-foreground bg-background pl-12 py-6 font-medium shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] focus:translate-x-0.5 focus:translate-y-0.5 focus:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - /> - </div> - - <div className="flex gap-2"> - <Button - onClick={() => updateParams({ query: searchQuery })} - className="border-3 border-foreground bg-primary px-6 py-6 font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - Search - </Button> - <Link href={`/properties/map?${searchParams.toString()}`}> - <Button className="border-3 border-foreground bg-background px-6 py-6 font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> - <Map className="mr-2 h-5 w-5" /> - Map View - </Button> - </Link> - {canCompareProperties && ( - <Link href={`/properties/compare?${searchParams.toString()}`}> - <Button className="border-3 border-foreground bg-background px-6 py-6 font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> - <Scale className="mr-2 h-5 w-5" /> - Compare ({compareIds.length}) - </Button> - </Link> - )} - <Button - onClick={() => setShowFilters(!showFilters)} - className="border-3 border-foreground bg-background px-6 py-6 font-bold text-foreground shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - <SlidersHorizontal className="mr-2 h-5 w-5" /> - Filters - {hasActiveFilters && ( - <span className="ml-2 flex h-6 w-6 items-center justify-center bg-primary text-xs font-bold"> - ! - </span> - )} - </Button> - </div> - </div> - - {/* Filter Panel */} - {showFilters && ( - <div className="mt-6 border-3 border-foreground bg-background p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-center justify-between mb-4"> - <h3 className="font-mono text-lg font-bold"> - Filter Properties - </h3> - <Button - variant="ghost" - size="sm" - onClick={clearAllFilters} - className="text-sm underline" - > - Clear All - </Button> - </div> - - <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> - {/* City */} - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - City - </p> - <div className="flex flex-wrap gap-2"> - {CITIES.map((c) => ( - <button - key={c} - onClick={() => - updateParams({ city: city === c ? "" : c }) - } - className={`border-2 border-foreground px-3 py-2 text-sm font-medium transition-all ${ - city === c - ? "bg-foreground text-background" - : "bg-background hover:bg-muted" - }`} - > - {c} - </button> - ))} - </div> - </div> - - {/* Area */} - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Neighbourhood - </p> - <Input - type="text" - placeholder="e.g. Lekki, Victoria Island" - defaultValue={area} - onChange={(e) => { - const val = e.target.value; - const timeout = setTimeout(() => updateParams({ area: val }), 400); - (e.target as HTMLInputElement).dataset.debounce = String(timeout); - }} - onBlur={(e) => { - const timeout = parseInt(e.target.dataset.debounce || "0", 10); - if (timeout) clearTimeout(timeout); - updateParams({ area: e.target.value }); - }} - className="border-2 border-foreground bg-background" - /> - </div> - - {/* Bedrooms */} - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Bedrooms - </p> - <div className="flex flex-wrap gap-2"> - {BED_OPTIONS.map((beds) => ( - <button - key={beds} - onClick={() => - updateParams({ - minBedrooms: - beds === "Any" - ? "" - : beds === "4+" - ? "4" - : beds, - maxBedrooms: - beds === "Any" - ? "" - : beds === "4+" - ? "" - : beds, - }) - } - className={`border-2 border-foreground px-4 py-2 text-sm font-medium transition-all ${ - (beds === "Any" && !minBedrooms && !maxBedrooms) || - (beds === "4+" && minBedrooms === "4" && !maxBedrooms) || - (beds !== "Any" && - beds !== "4+" && - minBedrooms === beds && - maxBedrooms === beds) - ? "bg-foreground text-background" - : "bg-background hover:bg-muted" - }`} - > - {beds} - </button> - ))} - </div> - </div> - - {/* Bathrooms */} - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Bathrooms - </p> - <div className="flex flex-wrap gap-2"> - {BATH_OPTIONS.map((baths) => ( - <button - key={baths} - onClick={() => - updateParams({ - minBathrooms: - baths === "Any" - ? "" - : baths === "3+" - ? "3" - : baths, - maxBathrooms: - baths === "Any" - ? "" - : baths === "3+" - ? "" - : baths, - }) - } - className={`border-2 border-foreground px-4 py-2 text-sm font-medium transition-all ${ - (baths === "Any" && !minBathrooms && !maxBathrooms) || - (baths === "3+" && minBathrooms === "3" && !maxBathrooms) || - (baths !== "Any" && - baths !== "3+" && - minBathrooms === baths && - maxBathrooms === baths) - ? "bg-foreground text-background" - : "bg-background hover:bg-muted" - }`} - > - {baths} - </button> - ))} - </div> - </div> - - {/* Price Range */} - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Annual Rent Range - </p> - <div className="flex gap-2"> - <Input - type="number" - placeholder="Min" - value={minAnnualRent} - onChange={(e) => - updateParams({ minAnnualRent: e.target.value }) - } - className="border-2 border-foreground bg-background" - /> - <span className="flex items-center">-</span> - <Input - type="number" - placeholder="Max" - value={maxAnnualRent} - onChange={(e) => - updateParams({ maxAnnualRent: e.target.value }) - } - className="border-2 border-foreground bg-background" - /> - </div> - </div> - - {/* Sort */} - <div> - <p className="mb-2 block font-mono text-sm font-bold"> - Sort By - </p> - <div className="flex flex-wrap gap-2"> - {SORT_OPTIONS.map((option) => ( - <button - key={option.value} - onClick={() => updateParams({ sortBy: option.value })} - className={`border-2 border-foreground px-3 py-2 text-sm font-medium transition-all ${ - sortBy === option.value - ? "bg-foreground text-background" - : "bg-background hover:bg-muted" - }`} - > - {option.label} - </button> - ))} - </div> - </div> - </div> - </div> - )} - - {/* Active Filters Display */} - {hasActiveFilters && ( - <div className="mt-4 flex flex-wrap items-center gap-2"> - <span className="text-sm text-muted-foreground">Active:</span> - {city && ( - <Badge - variant="secondary" - className="cursor-pointer border-2 border-foreground" - onClick={() => updateParams({ city: "" })} - > - {city} <X className="ml-1 h-3 w-3" /> - </Badge> - )} - {area && ( - <Badge - variant="secondary" - className="cursor-pointer border-2 border-foreground" - onClick={() => updateParams({ area: "" })} - > - {area} <X className="ml-1 h-3 w-3" /> - </Badge> - )} - {(minBedrooms || maxBedrooms) && ( - <Badge - variant="secondary" - className="cursor-pointer border-2 border-foreground" - onClick={() => - updateParams({ minBedrooms: "", maxBedrooms: "" }) - } - > - {minBedrooms || "0"}-{maxBedrooms || "∞"} bed{" "} - <X className="ml-1 h-3 w-3" /> - </Badge> - )} - {(minAnnualRent || maxAnnualRent) && ( - <Badge - variant="secondary" - className="cursor-pointer border-2 border-foreground" - onClick={() => - updateParams({ - minAnnualRent: "", - maxAnnualRent: "", - }) - } - > - {minAnnualRent - ? formatPrice(parseInt(minAnnualRent, 10)) - : "₦0"}{" "} - -{" "} - {maxAnnualRent - ? formatPrice(parseInt(maxAnnualRent, 10)) - : "∞"}{" "} - <X className="ml-1 h-3 w-3" /> - </Badge> - )} - <Button - variant="ghost" - size="sm" - onClick={clearAllFilters} - className="text-xs underline" - > - Clear all - </Button> - </div> - )} - </div> - </section> - - {/* Properties Grid */} - <section className="py-12"> - <div className="container mx-auto px-4"> - <div className="mb-6 flex items-center justify-between"> - <p className="text-muted-foreground"> - Showing{" "} - <span className="font-bold text-foreground">{total}</span>{" "} - properties - </p> - </div> - - {isLoading ? ( - <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> - {Array.from({ length: 8 }).map((_, i) => ( - <PropertyCardSkeleton key={i} /> - ))} - </div> - ) : properties.length === 0 ? ( - <div className="border-3 border-foreground bg-muted p-12 text-center shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <SearchX className="mx-auto h-16 w-16 text-muted-foreground" /> - <p className="font-mono text-xl font-bold mb-2 mt-4"> - No properties found - </p> - <p className="text-muted-foreground"> - Try adjusting your filters or search query. - </p> - <Button - onClick={clearAllFilters} - className="mt-6 border-3 border-foreground bg-primary font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" - > - Clear Filters - </Button> - </div> - ) : ( - <> - <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> - {properties.map((property) => ( - <PropertyCard - key={property.listingId} - property={propertyListingToCard(property)} - isFavorited={savedListingIds.includes(property.listingId)} - onFavoriteChange={(saved) => - handleFavoriteChange(property.listingId, saved) - } - showCompare={true} - /> - ))} - </div> - - {/* Pagination */} - {totalPages > 1 && ( - <div className="mt-8 flex items-center justify-center gap-2"> - <Button - variant="outline" - disabled={page <= 1} - onClick={() => updateParams({ page: String(page - 1) })} - className="border-2 border-foreground font-bold" - > - Previous - </Button> - <span className="px-4 font-mono font-bold"> - Page {page} of {totalPages} - </span> - <Button - variant="outline" - disabled={page >= totalPages} - onClick={() => updateParams({ page: String(page + 1) })} - className="border-2 border-foreground font-bold" - > - Next - </Button> - </div> - )} - </> - )} - </div> - </section> - </main> - ); -} +/** + * 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 ( - <Suspense - fallback={ - <div className="min-h-screen bg-background flex items-center justify-center"> - <p className="font-mono font-bold text-muted-foreground"> - Loading properties... - </p> - </div> - } - > - <PropertiesContent /> - </Suspense> - ); + return <PropertiesClient />; } 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<SavedProperty[]>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<string | null>(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() { </h1> <p className="text-sm text-muted-foreground"> {isLoading - ? "Loading..." + ? "Loading your shortlist…" : `${visibleProperties.length} saved ${visibleProperties.length === 1 ? "property" : "properties"}`} </p> </div> @@ -146,42 +154,33 @@ export default function SavedPropertiesPage() { {/* Loading state */} {isLoading && ( - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + <LoadingState + label="Loading saved properties" + className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3" + > {Array.from({ length: 6 }).map((_, i) => ( <PropertyCardSkeleton key={i} /> ))} - </div> + </LoadingState> )} {/* Error state */} {!isLoading && error && ( - <Card className="border-3 border-foreground p-8 text-center shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <AlertCircle className="mx-auto h-12 w-12 text-destructive" /> - <p className="mt-4 text-lg font-bold">{error}</p> - <Button - onClick={() => window.location.reload()} - className="mt-4 border-3 border-foreground bg-primary font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" - > - Try Again - </Button> - </Card> + <ErrorState + title="Could not load your saved properties" + description={error} + onRetry={retry} + /> )} {/* Empty state */} {!isLoading && !error && visibleProperties.length === 0 && ( - <Card className="border-3 border-dashed border-foreground p-12 text-center shadow-none"> - <Heart className="mx-auto h-16 w-16 text-muted-foreground" /> - <h2 className="mt-4 text-xl font-bold">No saved properties yet</h2> - <p className="mt-2 text-muted-foreground"> - Browse properties and tap the heart icon to save your favorites here. - </p> - <Link href="/properties" className="mt-6 inline-block"> - <Button className="border-3 border-foreground bg-primary font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] transition-all hover:translate-x-0.5 hover:translate-y-0.5 hover:shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]"> - <Search className="mr-2 h-4 w-4" /> - Browse Properties - </Button> - </Link> - </Card> + <EmptyState + icon={Heart} + title="No saved properties yet" + description="Tap the heart icon on any listing to save it here, then compare your shortlist side by side." + action={{ label: "Browse properties", href: "/properties" }} + /> )} {/* Property grid */} diff --git a/frontend/app/public/tenant-rating/[token]/layout.tsx b/frontend/app/public/tenant-rating/[token]/layout.tsx new file mode 100644 index 000000000..68c13d25d --- /dev/null +++ b/frontend/app/public/tenant-rating/[token]/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Token-addressed tenant rating card. Anyone holding the link can open it, so beyond noindex this also sets noarchive/nosnippet — a cached copy or search snippet would leak a tenant's payment record after the token is revoked. + */ +export const metadata: Metadata = privatePageMetadata("Tenant Rating Card"); + +export default function SharedTenantRatingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/rating-card/[token]/layout.tsx b/frontend/app/rating-card/[token]/layout.tsx index 360732129..8e0351ecc 100644 --- a/frontend/app/rating-card/[token]/layout.tsx +++ b/frontend/app/rating-card/[token]/layout.tsx @@ -1,6 +1,16 @@ import type { Metadata } from "next"; +import { NO_INDEX } from "@/lib/seo"; +/** + * Token-addressed rating card. + * + * The link is shareable by design, which is exactly why it must never reach a + * search index: the token is the only access control, and an indexed — or + * merely cached or snippeted — copy would outlive its revocation and expose a + * named tenant's payment history. Hence noarchive/nosnippet alongside noindex. + */ export const metadata: Metadata = { + robots: NO_INDEX, title: "Tenant Rating Card — Shelterflex", description: "View this tenant's verified reputation score, payment history, and landlord ratings on Shelterflex.", diff --git a/frontend/app/report/layout.tsx b/frontend/app/report/layout.tsx new file mode 100644 index 000000000..8126b4e34 --- /dev/null +++ b/frontend/app/report/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Authenticated reporting flow — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Report"); + +export default function ReportLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/robots.ts b/frontend/app/robots.ts new file mode 100644 index 000000000..991da6206 --- /dev/null +++ b/frontend/app/robots.ts @@ -0,0 +1,46 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/seo"; + +/** + * Crawler-level backstop for the private and token-addressed routes. + * + * The per-route `robots` metadata is the primary control — it is what a crawler + * honours once it has the page. This file stops well-behaved crawlers reaching + * those URLs in the first place, which matters most for the token routes, where + * the URL itself is the secret. + * + * Deliberately narrow: this is an exclusion list, not a sitemap or robots + * overhaul. + */ +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: "/", + disallow: [ + "/api/", + "/admin/", + "/dashboard/", + "/wallet", + "/messages", + "/onboarding", + "/pre-screen", + "/report", + "/staking", + "/tenant/", + "/verify-otp", + "/forgot-password", + "/offline", + "/whistleblower/dashboard", + "/whistleblower/earnings", + // Token-addressed rating cards: the link is the access control, so + // these must never be fetched, cached, or indexed. + "/rating-card/", + "/public/tenant-rating/", + ], + }, + ], + host: SITE_URL, + }; +} diff --git a/frontend/app/staking/layout.tsx b/frontend/app/staking/layout.tsx new file mode 100644 index 000000000..4f4e3cbbe --- /dev/null +++ b/frontend/app/staking/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Wallet-connected staking surface — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Staking"); + +export default function StakingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/tenant/layout.tsx b/frontend/app/tenant/layout.tsx new file mode 100644 index 000000000..191b2a4a9 --- /dev/null +++ b/frontend/app/tenant/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Authenticated tenant-only pages — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Tenant"); + +export default function TenantSectionLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/terms/page.tsx b/frontend/app/terms/page.tsx index 829a0b251..98b3026a7 100644 --- a/frontend/app/terms/page.tsx +++ b/frontend/app/terms/page.tsx @@ -13,7 +13,7 @@ export const dynamic = "force-static"; * engines cannot deliver a user to the placeholder version. */ export const metadata: Metadata = { - title: "Terms of Service — Shelterflex", + title: "Terms of Service", description: "Read the Shelterflex Terms of Service. Official legal copy will be updated before launch.", alternates: { canonical: "/terms-of-service" }, diff --git a/frontend/app/verify-otp/layout.tsx b/frontend/app/verify-otp/layout.tsx new file mode 100644 index 000000000..120c88e3f --- /dev/null +++ b/frontend/app/verify-otp/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * One-time-code step of the auth flow — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Verify Code"); + +export default function VerifyOtpLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/wallet/page.tsx b/frontend/app/wallet/page.tsx index ae19e3933..b3b75f1fa 100644 --- a/frontend/app/wallet/page.tsx +++ b/frontend/app/wallet/page.tsx @@ -1,4 +1,10 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + // Server component — owns the route segment config export const dynamic = "force-dynamic"; +/** Shows the signed-in user's balances and ledger; never indexable. */ +export const metadata: Metadata = privatePageMetadata("Wallet"); + export { default } from "./WalletClient"; diff --git a/frontend/app/whistleblower/dashboard/layout.tsx b/frontend/app/whistleblower/dashboard/layout.tsx new file mode 100644 index 000000000..22021d02f --- /dev/null +++ b/frontend/app/whistleblower/dashboard/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Authenticated reporter dashboard — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Whistleblower Dashboard"); + +export default function WhistleblowerDashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/whistleblower/dashboard/page.tsx b/frontend/app/whistleblower/dashboard/page.tsx index 7cb79f550..320ad09dd 100644 --- a/frontend/app/whistleblower/dashboard/page.tsx +++ b/frontend/app/whistleblower/dashboard/page.tsx @@ -1,10 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { DashboardHeader } from "@/components/dashboard-header"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { + EmptyState, + ErrorState, + ListRowSkeleton, + LoadingState, + StatCardSkeleton, +} from "@/components/ui/data-state"; import { Plus, TrendingUp, @@ -14,7 +21,6 @@ import { Star, DollarSign, Home, - Loader2, } from "lucide-react"; import { getWhistleblowerDashboardData, @@ -33,30 +39,45 @@ export default function WhistleblowerDashboard() { const [error, setError] = useState<string | null>(null); const [data, setData] = useState<WhistleblowerDashboardData | null>(null); - useEffect(() => { - async function fetchData() { - try { - setLoading(true); - const result = await getWhistleblowerDashboardData(); - setData(result); - setError(null); - } catch (err) { - console.error("Failed to fetch whistleblower data:", err); - setError( - "Failed to connect to live data. Please ensure the backend is running.", - ); - } finally { - setLoading(false); - } + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const result = await getWhistleblowerDashboardData(); + setData(result); + setError(null); + } catch (err) { + console.error("Failed to fetch whistleblower data:", err); + setError( + "Failed to connect to live data. Please ensure the backend is running.", + ); + } finally { + setLoading(false); } - fetchData(); }, []); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (loading) { return ( - <div className="flex h-screen w-full flex-col items-center justify-center bg-background"> - <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> - <p className="font-mono text-lg font-bold">Loading your dashboard...</p> + <div className="min-h-screen bg-background p-4 pt-24 md:p-8 md:pt-28"> + <LoadingState + label="Loading your whistleblower dashboard" + className="mx-auto max-w-5xl space-y-8" + > + <div className="grid gap-4 md:grid-cols-4"> + {Array.from({ length: 4 }).map((_, index) => ( + <StatCardSkeleton key={`wb-stat-${index}`} /> + ))} + </div> + <div className="space-y-3"> + {Array.from({ length: 3 }).map((_, index) => ( + <ListRowSkeleton key={`wb-row-${index}`} /> + ))} + </div> + </LoadingState> </div> ); } @@ -68,20 +89,14 @@ export default function WhistleblowerDashboard() { if (error && !data) { return ( - <div className="flex h-screen w-full flex-col items-center justify-center bg-background p-4"> - <div className="max-w-md w-full border-3 border-destructive bg-destructive/10 p-8 text-center shadow-[8px_8px_0px_0px_rgba(26,26,26,1)]"> - <AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" /> - <h1 className="font-mono text-2xl font-black mb-2 text-destructive"> - Connection Error - </h1> - <p className="text-destructive/80 mb-6">{error}</p> - <Button - className="w-full border-3 border-foreground bg-primary font-bold shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]" - onClick={() => window.location.reload()} - > - Retry Connection - </Button> - </div> + <div className="flex min-h-screen w-full flex-col items-center justify-center bg-background p-4"> + <ErrorState + className="w-full max-w-md" + title="We couldn't reach your dashboard" + description={error} + onRetry={fetchData} + retryLabel="Retry connection" + /> </div> ); } @@ -234,15 +249,15 @@ export default function WhistleblowerDashboard() { <div className="space-y-4"> {listings.length === 0 ? ( - <div className="border-3 border-foreground border-dashed p-12 text-center bg-muted/30"> - <Home className="mx-auto h-12 w-12 text-muted-foreground mb-4 opacity-50" /> - <p className="font-mono text-lg font-bold"> - No active listings - </p> - <p className="text-muted-foreground mt-2"> - Report your first apartment to start earning! - </p> - </div> + <EmptyState + icon={Home} + title="No active listings" + description="Report a vacant apartment you know of — you earn a reward once a tenant rents it through Shelterflex." + action={{ + label: "Report an apartment", + href: "/whistleblower/report", + }} + /> ) : ( listings.map((listing) => ( <Card @@ -308,11 +323,15 @@ export default function WhistleblowerDashboard() { </h2> <div className="space-y-3"> {earnings.length === 0 ? ( - <div className="border-3 border-foreground border-dashed p-8 text-center bg-muted/30"> - <p className="text-muted-foreground italic"> - No recent earnings records found. - </p> - </div> + <EmptyState + icon={DollarSign} + title="No earnings yet" + description="Rewards land here after a tenant you reported completes their first payment." + action={{ + label: "Report an apartment", + href: "/whistleblower/report", + }} + /> ) : ( earnings.map((earning, idx) => ( <Card diff --git a/frontend/app/whistleblower/earnings/layout.tsx b/frontend/app/whistleblower/earnings/layout.tsx new file mode 100644 index 000000000..882330582 --- /dev/null +++ b/frontend/app/whistleblower/earnings/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { privatePageMetadata } from "@/lib/seo"; + +/** + * Authenticated earnings record — not indexable. + */ +export const metadata: Metadata = privatePageMetadata("Whistleblower Earnings"); + +export default function WhistleblowerEarningsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}</>; +} diff --git a/frontend/app/whistleblower/earnings/page.tsx b/frontend/app/whistleblower/earnings/page.tsx index 419503485..a463d2b5a 100644 --- a/frontend/app/whistleblower/earnings/page.tsx +++ b/frontend/app/whistleblower/earnings/page.tsx @@ -7,9 +7,16 @@ import { DollarSign, CheckCircle, Clock, - AlertCircle, } from "lucide-react"; import { Card } from "@/components/ui/card"; +import { + EmptyState, + ErrorState, + ListRowSkeleton, + LoadingState, + MoneyValue, + StatCardSkeleton, +} from "@/components/ui/data-state"; import useAuthStore from "@/store/useAuthStore"; import { getWhistleblowerEarnings, type EarningsResponse } from "@/lib/api/whistleblowerApplications"; import { useCurrency } from "@/contexts/CurrencyContext"; @@ -22,30 +29,49 @@ export default function WhistleblowerEarningsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); - useEffect(() => { - async function fetchEarnings() { - if (!user?.id) { - setError("User not authenticated"); - setLoading(false); - return; - } + const fetchEarnings = useCallback(async () => { + if (!user?.id) { + setError("User not authenticated"); + setLoading(false); + return; + } - try { - const data = await getWhistleblowerEarnings(user.id); - setEarningsData(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load earnings"); - } finally { - setLoading(false); - } + try { + const data = await getWhistleblowerEarnings(user.id); + setEarningsData(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load earnings"); + } finally { + setLoading(false); } + }, [user?.id]); + useEffect(() => { fetchEarnings(); - }, [user?.id]); + }, [fetchEarnings]); + + const retry = useCallback(() => { + setLoading(true); + setError(null); + fetchEarnings(); + }, [fetchEarnings]); + + // Amounts stay null until the server sends them, so an unreachable earnings + // service dashes out rather than reporting a ₦0 balance. + const totalEarnings = earningsData?.totals.totalNgn ?? null; + const completedEarnings = earningsData?.totals.paidNgn ?? null; + const pendingEarnings = earningsData?.totals.pendingNgn ?? null; - const totalEarnings = earningsData?.totals.totalNgn || 0; - const completedEarnings = earningsData?.totals.paidNgn || 0; - const pendingEarnings = earningsData?.totals.pendingNgn || 0; + const moneyStatus: "loading" | "error" | "ready" = loading + ? "loading" + : error || !earningsData + ? "error" + : "ready"; + + /** Pairs an NGN figure with its USDC counterpart for the dual-currency display. */ + const formatPair = (usdc: number | undefined) => (ngn: number) => + formatAmount(ngn, usdc ?? 0); // Map backend status to frontend status const mapStatus = (status: string): "completed" | "pending" => { @@ -84,27 +110,27 @@ export default function WhistleblowerEarningsPage() { {/* Loading State */} {loading && ( - <Card className="border-3 border-foreground p-8 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-center justify-center gap-3"> - <div className="h-6 w-6 animate-spin border-3 border-foreground border-t-primary rounded-full" /> - <p className="font-bold">Loading earnings...</p> + <LoadingState label="Loading your earnings" className="space-y-8"> + <div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6"> + {Array.from({ length: 3 }).map((_, i) => ( + <StatCardSkeleton key={`wb-earning-stat-${i}`} /> + ))} + </div> + <div className="space-y-3"> + {Array.from({ length: 3 }).map((_, i) => ( + <ListRowSkeleton key={`wb-earning-row-${i}`} /> + ))} </div> - </Card> + </LoadingState> )} {/* Error State */} - {error && ( - <Card className="border-3 border-destructive p-8 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="flex items-start gap-3"> - <AlertCircle className="h-6 w-6 text-destructive shrink-0 mt-0.5" /> - <div> - <p className="font-bold text-destructive mb-1"> - Failed to Load Earnings - </p> - <p className="text-sm text-destructive/80">{error}</p> - </div> - </div> - </Card> + {!loading && error && ( + <ErrorState + title="Failed to load earnings" + description={error} + onRetry={retry} + /> )} {/* Content State */} @@ -122,16 +148,24 @@ export default function WhistleblowerEarningsPage() { Total Earnings </p> <p className="text-2xl font-black md:text-3xl"> - {formatAmount( - totalEarnings, - earningsData.totals.totalUsdc ?? 0, - )} + <MoneyValue + status={moneyStatus} + amount={totalEarnings} + format={formatPair(earningsData.totals.totalUsdc)} + skeletonClassName="h-8 w-32" + unavailableLabel="Total earnings unavailable" + /> </p> <p className="text-xs text-muted-foreground mt-1"> - {formatDual( - totalEarnings, - earningsData.totals.totalUsdc ?? 0, - )} + <MoneyValue + status={moneyStatus} + amount={totalEarnings} + format={(ngn) => + formatDual(ngn, earningsData.totals.totalUsdc ?? 0) + } + skeletonClassName="h-3 w-24" + unavailableLabel="Total earnings unavailable" + /> </p> </div> </div> @@ -147,10 +181,13 @@ export default function WhistleblowerEarningsPage() { Completed </p> <p className="text-2xl font-black md:text-3xl"> - {formatAmount( - completedEarnings, - earningsData.totals.paidUsdc ?? 0, - )} + <MoneyValue + status={moneyStatus} + amount={completedEarnings} + format={formatPair(earningsData.totals.paidUsdc)} + skeletonClassName="h-8 w-32" + unavailableLabel="Completed earnings unavailable" + /> </p> </div> </div> @@ -166,10 +203,13 @@ export default function WhistleblowerEarningsPage() { Pending </p> <p className="text-2xl font-black md:text-3xl"> - {formatAmount( - pendingEarnings, - earningsData.totals.pendingUsdc ?? 0, - )} + <MoneyValue + status={moneyStatus} + amount={pendingEarnings} + format={formatPair(earningsData.totals.pendingUsdc)} + skeletonClassName="h-8 w-32" + unavailableLabel="Pending earnings unavailable" + /> </p> </div> </div> @@ -182,14 +222,15 @@ export default function WhistleblowerEarningsPage() { Earnings History </h2> {earningsData.history.length === 0 ? ( - <Card className="border-3 border-foreground p-8 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]"> - <div className="text-center"> - <p className="text-muted-foreground mb-2">No earnings yet</p> - <p className="text-sm text-muted-foreground"> - Start reporting vacant apartments to earn rewards - </p> - </div> - </Card> + <EmptyState + icon={DollarSign} + title="No earnings yet" + description="Report a vacant apartment — you earn a reward once a tenant rents it through Shelterflex." + action={{ + label: "Report an apartment", + href: "/whistleblower/report", + }} + /> ) : ( <div className="space-y-3"> {earningsData.history.map((earning) => { diff --git a/frontend/components/admin/KPICard.tsx b/frontend/components/admin/KPICard.tsx index 6945952dc..f21877bc8 100644 --- a/frontend/components/admin/KPICard.tsx +++ b/frontend/components/admin/KPICard.tsx @@ -1,10 +1,15 @@ import React from "react"; import { ArrowUpRight, ArrowDownRight, Minus } from "lucide-react"; import { ResponsiveContainer, AreaChart, Area } from "recharts"; +import { LoadingState } from "@/components/ui/data-state"; export interface KPICardProps { title: string; - value: string | number; + /** + * Accepts a node so callers can pass `<MoneyValue>` and keep monetary figures + * out of the "render a fallback number" path. + */ + value: React.ReactNode; change?: number; // e.g. 12.4 for +12.4%, -3.2 for -3.2% changeLabel?: string; // e.g. "vs last month" sparklineData?: number[]; // array of trend values @@ -32,13 +37,16 @@ export function KPICard({ if (isLoading) { return ( - <div className="border-3 border-foreground bg-card p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] animate-pulse flex flex-col justify-between h-40"> + <LoadingState + label={`Loading ${title}`} + className="border-3 border-foreground bg-card p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] animate-pulse flex flex-col justify-between h-40" + > <div> <div className="h-4 w-24 bg-muted border-2 border-foreground/10 mb-2"></div> <div className="h-8 w-32 bg-muted border-2 border-foreground/10"></div> </div> <div className="h-4 w-40 bg-muted border-2 border-foreground/10"></div> - </div> + </LoadingState> ); } diff --git a/frontend/components/inspector/JobCard.tsx b/frontend/components/inspector/JobCard.tsx index 46aa7d711..8e2b3cd10 100644 --- a/frontend/components/inspector/JobCard.tsx +++ b/frontend/components/inspector/JobCard.tsx @@ -5,6 +5,7 @@ import { MapPin, Clock, DollarSign, FileText, CheckCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { MoneyValue } from "@/components/ui/data-state"; import type { InspectionJob } from "@/lib/propertyInspectionApi"; interface JobCardProps { @@ -82,7 +83,13 @@ export function JobCard({ job, onClaim, isClaiming }: JobCardProps) { <div className="flex gap-6 text-sm"> <div className="flex items-center gap-1 font-medium text-foreground"> <DollarSign className="h-4 w-4 text-primary" /> - ₦{(job.offeredFee || 0).toLocaleString()} + <MoneyValue + status="ready" + amount={job.offeredFee} + format={(fee) => `₦${fee.toLocaleString()}`} + skeletonClassName="h-4 w-20" + unavailableLabel="Fee unavailable" + /> </div> <div className="flex items-center gap-1 text-muted-foreground"> <Clock className="h-4 w-4" /> diff --git a/frontend/components/property-card.tsx b/frontend/components/property-card.tsx index f60134ab8..08a39903c 100644 --- a/frontend/components/property-card.tsx +++ b/frontend/components/property-card.tsx @@ -27,6 +27,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { MoneyValue } from "@/components/ui/data-state"; import { LandlordVerificationBadge } from "@/components/LandlordVerificationBadge"; import { setListingSaved } from "@/lib/savedPropertiesApi"; import { showErrorToast } from "@/lib/toast"; @@ -309,10 +310,22 @@ export function PropertyCard({ const priceBlock = showBothPrices ? ( <> <p className="text-xs text-muted-foreground"> - {formatNgn(property.installmentBasePriceNgn ?? 0)}/yr (installment) + <MoneyValue + status="ready" + amount={property.installmentBasePriceNgn} + format={formatNgn} + skeletonClassName="h-3 w-20" + unavailableLabel="Installment price unavailable" + /> + /yr (installment) </p> <p className="font-mono text-xl font-black"> - {formatNgn(property.outrightPriceNgn ?? 0)}{" "} + <MoneyValue + status="ready" + amount={property.outrightPriceNgn} + format={formatNgn} + unavailableLabel="Outright price unavailable" + />{" "} <span className="text-xs font-medium text-muted-foreground">outright</span> </p> </> diff --git a/frontend/components/staking/HistoryTable.tsx b/frontend/components/staking/HistoryTable.tsx index ca3ef4a17..7042174c2 100644 --- a/frontend/components/staking/HistoryTable.tsx +++ b/frontend/components/staking/HistoryTable.tsx @@ -6,6 +6,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { getStakingHistory, type StakingHistoryItem } from "@/lib/config"; import { Loader2, AlertCircle, History, ArrowUpRight, ArrowDownLeft, Gift, ExternalLink, RefreshCw } from "lucide-react"; import { formatUsdc } from "./PositionCard"; +import { + ErrorState, + ListRowSkeleton, + LoadingState, +} from "@/components/ui/data-state"; interface HistoryTableProps { walletAddress?: string | null; @@ -116,24 +121,19 @@ export function HistoryTable({ walletAddress }: HistoryTableProps) { </CardHeader> <CardContent className="p-0"> {isLoading ? ( - <div className="flex flex-col items-center justify-center py-20 text-center space-y-3"> - <Loader2 className="h-8 w-8 animate-spin text-primary" /> - <p className="text-sm text-muted-foreground font-medium">Retrieving transaction history...</p> - </div> + <LoadingState label="Loading staking history" className="space-y-3 p-4"> + {Array.from({ length: 4 }).map((_, i) => ( + <ListRowSkeleton key={i} /> + ))} + </LoadingState> ) : error ? ( - <div className="flex flex-col items-center justify-center py-16 text-center px-4 space-y-3"> - <AlertCircle className="h-8 w-8 text-destructive" /> - <div> - <p className="text-sm font-bold text-foreground">{error}</p> - <button - type="button" - onClick={fetchHistory} - className="mt-2 text-xs font-semibold text-primary hover:underline" - > - Try refreshing the ledger - </button> - </div> - </div> + <ErrorState + className="m-4" + title="Staking history is unavailable" + description={error} + onRetry={fetchHistory} + retryLabel="Reload ledger" + /> ) : history.length === 0 ? ( <div className="flex flex-col items-center justify-center py-20 text-center px-4 space-y-4"> <div className="rounded-full bg-muted p-4 border border-foreground/5"> 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( + <MoneyValue status="loading" amount={undefined} format={formatNgn} />, + ); + + 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( + <MoneyValue + status="loading" + amount={undefined} + format={formatNgn} + loadingLabel="Loading balance" + />, + ); + + expect(screen.getByRole("status")).toHaveTextContent("Loading balance"); + }); + + it("renders an explicit dash rather than a number on error", () => { + const { container } = render( + <MoneyValue + status="error" + amount={undefined} + format={formatNgn} + unavailableLabel="Balance unavailable" + />, + ); + + 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( + <MoneyValue status="ready" amount={amount} format={formatNgn} />, + ); + expect(container.textContent).toContain("—"); + expect(container.textContent).not.toMatch(/0/); + unmount(); + } + }); + + it("formats a real amount once it has arrived", () => { + const { container } = render( + <MoneyValue status="ready" amount={4500000} format={formatNgn} />, + ); + + expect(container.textContent).toContain("4,500,000"); + }); + + it("still renders a genuine zero it was given", () => { + const { container } = render( + <MoneyValue status="ready" amount={0} format={formatNgn} />, + ); + + expect(container.textContent).toContain("0"); + expect(container.textContent).not.toContain("—"); + }); +}); + +describe("LoadingState", () => { + it("announces the fetch and hides the placeholder shapes", () => { + const { container } = render( + <LoadingState label="Loading payment history"> + <div data-testid="shape" /> + </LoadingState>, + ); + + 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(<LoadingAnnouncer label="Loading stats" />); + 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( + <ErrorState + title="Failed to load payouts" + description="Network request failed" + onRetry={onRetry} + />, + ); + + 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(<ErrorState description="boom" onRetry={() => {}} />); + 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( + <EmptyState + title="No saved properties yet" + description="Tap the heart icon on any listing to save it here." + action={{ label: "Browse properties", href: "/properties" }} + />, + ); + + 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( + <EmptyState + title="No properties found" + description="Clearing your filters widens the search." + action={{ label: "Clear filters", onClick }} + />, + ); + + 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(<EmptyState title="Nothing here" description="Yet." />); + 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 -> <LoadingState> (or <LoadingAnnouncer> + bare skeletons) + * error -> <ErrorState onRetry={...}> + * empty -> <EmptyState> 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: <MoneyValue>. + */ + +/** 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 ( + <span role="status" aria-live="polite" className="sr-only"> + {label} + </span> + ); +} + +/** + * 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 ( + <> + <LoadingAnnouncer label={label} /> + <div data-slot="loading-state" aria-hidden="true" className={className} {...props}> + {children} + </div> + </> + ); +} + +/** + * 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 ( + <div + className={cn( + "border-3 border-foreground bg-card p-3 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)] md:p-6", + className, + )} + > + <div className="flex items-center gap-2 md:gap-4"> + <Skeleton className="h-10 w-10 shrink-0 md:h-14 md:w-14" /> + <div className="min-w-0 flex-1 space-y-2"> + <Skeleton className="h-3 w-20" /> + <Skeleton className="h-6 w-24 md:h-8" /> + </div> + </div> + </div> + ); +} + +/** Placeholder for one row of a list or ledger. */ +export function ListRowSkeleton({ className }: { className?: string }) { + return ( + <div + className={cn( + "flex items-center justify-between gap-4 border-b-2 border-foreground/10 pb-3", + className, + )} + > + <div className="flex-1 space-y-2"> + <Skeleton className="h-4 w-40" /> + <Skeleton className="h-3 w-56" /> + </div> + <div className="space-y-2 text-right"> + <Skeleton className="ml-auto h-4 w-24" /> + <Skeleton className="ml-auto h-5 w-20" /> + </div> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 ( + <div + role="alert" + data-slot="error-state" + className={cn( + "flex flex-col items-start gap-3 border-3 border-destructive bg-destructive/10 p-6 shadow-[4px_4px_0px_0px_rgba(26,26,26,1)]", + className, + )} + > + <div className="flex items-start gap-3"> + <AlertTriangle className="h-5 w-5 shrink-0 text-destructive" aria-hidden="true" /> + <div className="space-y-1"> + <p className="font-bold text-foreground">{title}</p> + {description ? ( + <p className="text-sm text-muted-foreground">{description}</p> + ) : null} + </div> + </div> + <Button + type="button" + onClick={onRetry} + variant="outline" + className="border-3 border-foreground bg-background font-bold shadow-[2px_2px_0px_0px_rgba(26,26,26,1)]" + > + <RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" /> + {retryLabel} + </Button> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 ( + <div + data-slot="empty-state" + className={cn( + "flex flex-col items-center justify-center gap-3 border-3 border-dashed border-foreground bg-card p-8 text-center", + className, + )} + > + {Icon ? ( + <Icon className="h-12 w-12 text-muted-foreground" aria-hidden="true" /> + ) : null} + <div className="space-y-1"> + <p className="text-lg font-bold text-foreground">{title}</p> + <p className="mx-auto max-w-sm text-sm text-muted-foreground">{description}</p> + </div> + {action ? ( + action.href ? ( + <Button asChild className={actionClassName}> + <Link href={action.href}>{action.label}</Link> + </Button> + ) : ( + <Button type="button" onClick={action.onClick} className={actionClassName}> + {action.label} + </Button> + ) + ) : null} + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* Money */ +/* -------------------------------------------------------------------------- */ + +/** Rendered in place of an amount that is not known. Never a number. */ +export const MONEY_UNAVAILABLE = "—"; + +/** + * A monetary value that refuses to invent one. + * + * A balance or total rendered from a `?? 0` fallback is indistinguishable from + * a real zero, so a user can be shown — and believe — an amount the server + * never sent. This component renders a skeleton while loading and an explicit + * dash when the amount is unknown, and only ever formats a number it was + * actually given. + */ +export function MoneyValue({ + status, + amount, + format, + className, + skeletonClassName = "h-7 w-28", + loadingLabel = "Loading amount", + unavailableLabel = "Amount unavailable", +}: { + status: "loading" | "error" | "ready"; + /** The amount. `null`/`undefined` is treated as unknown, never as zero. */ + amount: number | null | undefined; + format: (amount: number) => string; + className?: string; + skeletonClassName?: string; + loadingLabel?: string; + unavailableLabel?: string; +}) { + if (status === "loading") { + return ( + <span className={cn("inline-flex items-center", className)}> + <LoadingAnnouncer label={loadingLabel} /> + <Skeleton className={skeletonClassName} /> + </span> + ); + } + + if (status === "error" || amount === null || amount === undefined || !Number.isFinite(amount)) { + return ( + <span + className={cn("text-muted-foreground", className)} + title={unavailableLabel} + data-slot="money-unavailable" + > + <span aria-hidden="true">{MONEY_UNAVAILABLE}</span> + <span className="sr-only">{unavailableLabel}</span> + </span> + ); + } + + return ( + <span className={className} data-slot="money-value"> + {format(amount)} + </span> + ); +} diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx index e3beb9024..928fcbf44 100644 --- a/frontend/components/ui/skeleton.tsx +++ b/frontend/components/ui/skeleton.tsx @@ -1,9 +1,15 @@ import { cn } from '@/lib/utils' +/** + * A placeholder shape. Hidden from assistive technology by default — the shapes + * carry no information, and the fetch itself is announced by `LoadingState` / + * `LoadingAnnouncer` in `data-state.tsx`. + */ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="skeleton" + aria-hidden="true" className={cn('bg-accent animate-pulse rounded-md', className)} {...props} /> diff --git a/frontend/lib/__tests__/no-money-fallbacks.test.ts b/frontend/lib/__tests__/no-money-fallbacks.test.ts new file mode 100644 index 000000000..d7d933bb8 --- /dev/null +++ b/frontend/lib/__tests__/no-money-fallbacks.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +/** + * Repository guard for the rule that motivated `<MoneyValue>`: a monetary value + * must never be rendered from a loading or error fallback. + * + * `formatNgn(balance ?? 0)` and `₦{total || 0}` are indistinguishable from a + * real zero once rendered, so a user can be shown — and act on — an amount the + * server never sent. This test fails the build if that pattern comes back. + */ + +const ROOT = join(__dirname, "..", ".."); +const SCAN_DIRS = ["app", "components", "lib", "hooks"]; +const SKIP_DIRS = new Set(["node_modules", ".next", "coverage", "e2e"]); + +/** Formatters whose output a user reads as money. */ +const MONEY_FORMATTERS = [ + "formatCurrency", + "formatNgn", + "formatUsdc", + "formatNaira", + "formatAmount", + "formatDual", + "formatMoney", +]; + +/** + * `formatNgn(x ?? 0)` / `formatCurrency(a.b || 0)` — a formatter called on an + * expression that falls back to a literal zero. + */ +const FORMATTER_FALLBACK = new RegExp( + String.raw`\b(?:${MONEY_FORMATTERS.join("|")})\s*\(\s*[^),]*?(?:\?\?|\|\|)\s*0(?:\.0+)?\s*[,)]`, +); + +/** + * `₦{amount ?? 0}` / `₦${(fee || 0).toLocaleString()}` — a naira-prefixed + * fallback. Anchored on the ₦ sign so a bare `${x ?? 0}` template hole, which + * is not necessarily money, does not trip the check. + */ +const CURRENCY_PREFIX_FALLBACK = + /₦\s*\{?\$?\{?\(?[^{}()\n]*?(?:\?\?|\|\|)\s*0(?:\.0+)?\s*[)}]/; + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + collectSourceFiles(full, out); + } else if (/\.tsx?$/.test(entry) && !/\.(test|spec)\.tsx?$/.test(entry)) { + out.push(full); + } + } + return out; +} + +describe("no monetary value renders from a fallback", () => { + const files = SCAN_DIRS.flatMap((dir) => collectSourceFiles(join(ROOT, dir))); + + it("scans the whole frontend source tree", () => { + // Guards against the walker silently matching nothing and passing vacuously. + expect(files.length).toBeGreaterThan(100); + }); + + it("has no money formatter called on a zero fallback", () => { + const offenders: string[] = []; + + for (const file of files) { + const source = readFileSync(file, "utf8"); + source.split("\n").forEach((line, index) => { + if (FORMATTER_FALLBACK.test(line)) { + offenders.push(`${relative(ROOT, file)}:${index + 1}: ${line.trim()}`); + } + }); + } + + expect( + offenders, + `Use <MoneyValue> so an unknown amount renders as a dash, not as zero:\n${offenders.join("\n")}`, + ).toEqual([]); + }); + + it("has no currency-prefixed zero fallback", () => { + const offenders: string[] = []; + + for (const file of files) { + const source = readFileSync(file, "utf8"); + source.split("\n").forEach((line, index) => { + if (CURRENCY_PREFIX_FALLBACK.test(line)) { + offenders.push(`${relative(ROOT, file)}:${index + 1}: ${line.trim()}`); + } + }); + } + + expect( + offenders, + `Use <MoneyValue> so an unknown amount renders as a dash, not as zero:\n${offenders.join("\n")}`, + ).toEqual([]); + }); +}); + +describe("error states offer a retry rather than a page reload", () => { + const files = SCAN_DIRS.flatMap((dir) => collectSourceFiles(join(ROOT, dir))); + + /** + * `window.location.reload()` throws away every other section on the page to + * recover one. The service worker and the offline fallback are the legitimate + * exceptions — there, reloading *is* the action. + */ + const ALLOWED_RELOADS = new Set([ + "components/service-worker-register.tsx", + "app/offline/page.tsx", + ]); + + it("has no reload-based retry outside the offline path", () => { + const offenders: string[] = []; + + for (const file of files) { + const rel = relative(ROOT, file).split("\\").join("/"); + if (ALLOWED_RELOADS.has(rel)) continue; + + const source = readFileSync(file, "utf8"); + source.split("\n").forEach((line, index) => { + if (/window\.location\.reload\s*\(/.test(line)) { + offenders.push(`${rel}:${index + 1}: ${line.trim()}`); + } + }); + } + + expect( + offenders, + `Use <ErrorState onRetry={...}> to re-run the failed fetch instead:\n${offenders.join("\n")}`, + ).toEqual([]); + }); +}); diff --git a/frontend/lib/seo.ts b/frontend/lib/seo.ts new file mode 100644 index 000000000..594c0169f --- /dev/null +++ b/frontend/lib/seo.ts @@ -0,0 +1,115 @@ +import type { Metadata } from "next"; + +/** + * Site-wide SEO helpers. + * + * Everything here runs on the server so crawlers and link unfurlers see the + * output in the initial HTML. Metadata set from a client component is invisible + * to both, so per-route metadata must live in a `page.tsx`/`layout.tsx` export + * rather than inside a `"use client"` component. + */ + +export const SITE_NAME = "Shelterflex"; + +export const DEFAULT_TITLE = "Shelterflex - Rent Now, Pay Later"; + +export const DEFAULT_DESCRIPTION = + "The smarter way to pay your rent. Split your rent payments into affordable monthly installments."; + +/** Fallback share image, used when a route has nothing more specific. */ +export const DEFAULT_OG_IMAGE = "/icon.svg"; + +/** + * Canonical origin. Configure `NEXT_PUBLIC_SITE_URL` per environment; the + * localhost default keeps `next build` working without extra setup, and + * `metadataBase` resolves every relative URL below against it. + */ +export const SITE_URL = ( + process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000" +).replace(/\/+$/, ""); + +/** Turns a route path into an absolute URL for canonical and OpenGraph tags. */ +export function absoluteUrl(path = "/"): string { + if (/^https?:\/\//i.test(path)) return path; + return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`; +} + +/** + * Robots directives for anything behind auth or reachable by a shared token. + * + * `noarchive` and `nosnippet` matter as much as `noindex` here: the rating-card + * routes are addressable by anyone holding the link, and a cached copy or a + * search snippet would leak a tenant's payment record even after the token is + * revoked. + */ +export const NO_INDEX: NonNullable<Metadata["robots"]> = { + index: false, + follow: false, + nocache: true, + noarchive: true, + nosnippet: true, + googleBot: { + index: false, + follow: false, + noimageindex: true, + }, +}; + +export interface PageMetadataInput { + title: string; + description: string; + /** Route path, e.g. `/properties`. Becomes the canonical URL. */ + path: string; + images?: Array<{ url: string; width?: number; height?: number; alt?: string }>; + /** Set for anything private or token-addressed. */ + noIndex?: boolean; + type?: "website" | "article" | "profile"; +} + +/** + * Builds a complete per-route Metadata object: canonical URL, OpenGraph, and + * Twitter card, all consistent with each other. + */ +export function buildPageMetadata({ + title, + description, + path, + images, + noIndex = false, + type = "website", +}: PageMetadataInput): Metadata { + const url = absoluteUrl(path); + const ogImages = images?.length + ? images + : [{ url: DEFAULT_OG_IMAGE, alt: SITE_NAME }]; + + return { + title, + description, + alternates: { canonical: url }, + ...(noIndex ? { robots: NO_INDEX } : {}), + openGraph: { + title, + description, + url, + siteName: SITE_NAME, + type, + images: ogImages, + }, + twitter: { + card: ogImages[0]?.url === DEFAULT_OG_IMAGE ? "summary" : "summary_large_image", + title, + description, + images: ogImages.map((image) => image.url), + }, + }; +} + +/** Convenience wrapper for private routes that still want a sensible title. */ +export function privatePageMetadata(title: string, description?: string): Metadata { + return { + title, + description: description ?? DEFAULT_DESCRIPTION, + robots: NO_INDEX, + }; +}