Full property listing with status tabs, district filter, sort and pagination (from Marbrook)
"use client";
import { SlidersHorizontal } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyTitle,
} from "@/components/ui/empty";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { Surface } from "../_types/common";
import type { PropertyListingContent } from "../_types/property";
import { defaultProperty2Content } from "./data";
import { PropertyCard } from "./property-card";
import { SectionHeader, surfaceClass, toneFor } from "./section-header";
const ALL = "all";
const pageLinkClasses =
"flex size-9 items-center justify-center rounded-md border border-transparent text-muted-foreground text-sm transition-colors hover:bg-background hover:text-primary";
const pageLinkActiveClasses = "border-input bg-background text-primary";
const pageStepClasses =
"flex h-9 items-center gap-1.5 px-3 text-muted-foreground text-sm transition-colors hover:text-primary";
/** `"$4,250,000"` and `"$9,400 / mo"` both reduce to a comparable integer. */
function priceValue(price: string): number {
return Number(price.replace(/[^\d]/g, "")) || 0;
}
/** The full book: status tabs, a district filter, sorting and pagination. */
export default function Property2({
content = defaultProperty2Content,
surface = "muted",
}: {
content?: PropertyListingContent;
surface?: Surface;
}) {
const [status, setStatus] = useState(ALL);
const [district, setDistrict] = useState(ALL);
const [sort, setSort] = useState(content.sorts[0]?.value ?? "newest");
const [page, setPage] = useState(1);
const matched = useMemo(() => {
const filtered = content.properties.filter(
(property) =>
(status === ALL || property.category === status) &&
(district === ALL || property.district === district),
);
return [...filtered].sort((a, b) => {
if (sort === "price-desc")
return priceValue(b.price) - priceValue(a.price);
if (sort === "price-asc")
return priceValue(a.price) - priceValue(b.price);
if (sort === "area-desc") return b.area - a.area;
return (b.year ?? 0) - (a.year ?? 0);
});
}, [content.properties, district, sort, status]);
const pageCount = Math.max(1, Math.ceil(matched.length / content.perPage));
const current = Math.min(page, pageCount);
const visible = matched.slice(
(current - 1) * content.perPage,
current * content.perPage,
);
/** Every filter change goes back to page one — page 4 of a two-page result
* is the classic way to land someone on an empty grid. */
function update(setter: (value: string) => void) {
return (value: unknown) => {
setter(String(value));
setPage(1);
};
}
function reset() {
setStatus(ALL);
setDistrict(ALL);
setSort(content.sorts[0]?.value ?? "newest");
setPage(1);
}
const sortLabel =
content.sorts.find((option) => option.value === sort)?.label ??
content.sorts[0]?.label;
const districtLabel =
content.districts.find((item) => item.value === district)?.label ??
content.districtsAll;
return (
<section className={cn("py-20 sm:py-28", surfaceClass[surface])}>
<div className="mx-auto max-w-7xl px-5 sm:px-8 lg:px-12">
<SectionHeader
action={
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Tabs onValueChange={update(setStatus)} value={status}>
<TabsList
className="w-full gap-0 border border-border bg-background p-0 sm:w-fit"
variant="default"
>
{content.statuses.map((item) => (
<TabsTab
className="h-11 px-4 text-muted-foreground text-sm data-active:text-primary sm:h-11 sm:text-sm"
key={item.value}
value={item.value}
>
{item.label}
</TabsTab>
))}
</TabsList>
</Tabs>
<Select onValueChange={update(setDistrict)} value={district}>
<SelectTrigger
aria-label="Filter by district"
className="h-11 min-h-0 w-full border-border bg-background text-primary text-sm shadow-none before:shadow-none sm:h-11 sm:w-56 sm:text-sm"
>
<SelectValue>{districtLabel}</SelectValue>
</SelectTrigger>
<SelectPopup>
<SelectItem value={ALL}>{content.districtsAll}</SelectItem>
{content.districts.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
}
actionAt="lg"
className="border-border border-b pb-8"
description={content.description}
eyebrow={content.eyebrow}
title={content.title}
tone={toneFor(surface)}
/>
<div className="mt-8 flex flex-wrap items-center justify-between gap-4">
<p className="text-muted-foreground text-sm">
{matched.length === 0
? "No matching homes"
: matched.length === 1
? "1 home matches"
: `${matched.length} homes match`}
{matched.length > 0 && pageCount > 1 && (
<span className="text-muted-foreground/70">
{" "}
· page {current} of {pageCount}
</span>
)}
</p>
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<SlidersHorizontal className="size-4" />
<Select onValueChange={update(setSort)} value={sort}>
<SelectTrigger
aria-label="Sort listings"
className="h-9 min-h-0 w-48 border-border bg-background text-primary text-sm shadow-none before:shadow-none sm:h-9 sm:text-sm"
>
<SelectValue>{sortLabel}</SelectValue>
</SelectTrigger>
<SelectPopup>
{content.sorts.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
</div>
{visible.length > 0 ? (
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((property, index) => (
<PropertyCard
key={property.href}
priority={index < 3}
property={property}
/>
))}
</div>
) : (
<Empty className="mt-8 rounded-lg border border-border border-dashed bg-card py-16">
<EmptyTitle className="font-display font-normal text-2xl text-primary">
{content.empty.title}
</EmptyTitle>
<EmptyDescription>{content.empty.description}</EmptyDescription>
<EmptyContent className="mt-6 flex flex-row flex-wrap justify-center gap-3">
{content.empty.actions?.map((action) => (
<Button
className="border-none bg-primary px-7 text-primary-foreground shadow-none hover:bg-primary/90"
key={action.href}
render={<Link href={action.href} />}
size="xl"
>
{action.label}
</Button>
))}
<Button
className="border-input bg-background px-7 text-primary shadow-none before:shadow-none hover:bg-muted"
onClick={reset}
size="xl"
variant="outline"
>
{content.resetLabel}
</Button>
</EmptyContent>
</Empty>
)}
{pageCount > 1 && (
<Pagination className="mt-12 border-border border-t pt-8">
<PaginationContent className="gap-1">
<PaginationItem>
<PaginationPrevious
className={cn(
pageStepClasses,
current === 1 && "pointer-events-none opacity-40",
)}
render={
<button
disabled={current === 1}
onClick={() => setPage(current - 1)}
type="button"
/>
}
/>
</PaginationItem>
{Array.from({ length: pageCount }, (_, index) => index + 1).map(
(number) => (
<PaginationItem key={number}>
<PaginationLink
className={cn(
pageLinkClasses,
number === current && pageLinkActiveClasses,
)}
isActive={number === current}
render={
<button onClick={() => setPage(number)} type="button" />
}
>
{number}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
className={cn(
pageStepClasses,
current === pageCount && "pointer-events-none opacity-40",
)}
render={
<button
disabled={current === pageCount}
onClick={() => setPage(current + 1)}
type="button"
/>
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</section>
);
}