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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"@tanstack/react-query-devtools": "^5.100.2",
"@tanstack/react-virtual": "^3.13.24",
"@wagmi/connectors": "^7.1.2",
"@wagmi/core": "^3.2.2",
"@wagmi/core": "3.4.0",
"@walletconnect/web3-provider": "^1.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
368 changes: 330 additions & 38 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/app/api/properties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {

// Parse query parameters
const page = parseInt(searchParams.get('page') || '1');
const resultsPerPage = parseInt(searchParams.get('limit') || '12');
const resultsPerPage = parseInt(searchParams.get('size') || searchParams.get('limit') || '12');
const sortBy = (searchParams.get('sortBy') || 'newest') as SortOption;
const useCache = searchParams.get('cache') !== 'false'; // Default to true

Expand Down
63 changes: 52 additions & 11 deletions src/app/properties/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { Suspense } from "react";
import React, { Suspense, useEffect } from "react";
import { SearchFilterForm } from "@/components/forms/SearchFilterForm";
import { SearchResults } from "@/components/SearchResults";
import { WalletConnector } from "@/components/WalletConnector";
Expand All @@ -12,15 +12,14 @@ import { useNotificationStore } from "@/store/notificationStore";
import { useWalletStore } from "@/store/walletStore";
import { useNotificationChecker } from "@/hooks/useNotificationChecker";
import { useFavoritesStore } from "@/store/favoritesStore";
import { usePaginationParams, isValidPageSize, type PageSize } from "@/hooks/usePaginationParams";
import Link from "next/link";
import { Heart } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import PropertyPageSkeleton from "@/components/PropertyPageSkeleton";

function PropertiesContent() {
const { viewMode: storeViewMode, setViewMode: setStoreViewMode } =
useSearchStore();
const { address } = useWalletStore();
const { alerts, markAsRead, markAllAsRead, clearAlert } =
useNotificationStore();

Expand All @@ -34,21 +33,50 @@ function PropertiesContent() {

const { favorites } = useFavoritesStore();

// URL-driven pagination params (?page=N&size=N)
const { page: urlPage, size: urlSize, setPage: setUrlPage, setSize: setUrlSize, buildHref } =
usePaginationParams();

const {
filters,
sortBy,
page,
page: storePage,
resultsPerPage: storeSize,
properties,
totalResults,
totalPages,
isLoading,
error,
setFilter,
setFilters,
clearFilters,
setSortBy,
setPage,
setPage: setStorePage,
setResultsPerPage,
} = usePropertySearch();

// Keep Zustand store in sync with URL params on mount and when URL changes
useEffect(() => {
if (urlPage !== storePage) {
setStorePage(urlPage);
}
}, [urlPage]); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
if (urlSize !== storeSize) {
setResultsPerPage(urlSize);
}
}, [urlSize]); // eslint-disable-line react-hooks/exhaustive-deps

// Page change: update URL (which triggers the effect above to sync the store)
const handlePageChange = (newPage: number) => {
setUrlPage(newPage);
};

// Page size change: update URL (resets to page 1 inside setUrlSize)
const handlePageSizeChange = (newSize: PageSize) => {
setUrlSize(newSize);
};

return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
Expand Down Expand Up @@ -109,8 +137,15 @@ function PropertiesContent() {
</h1>
<SearchFilterForm
filters={filters}
onApplyFilters={setFilter}
onClearFilters={clearFilters}
onApplyFilters={(newFilters) => {
// Apply full filter object and reset to page 1
setFilters(newFilters);
setUrlPage(1);
}}
onClearFilters={() => {
clearFilters();
setUrlPage(1);
}}
/>
</div>

Expand All @@ -123,12 +158,18 @@ function PropertiesContent() {
error={error}
viewMode={viewMode}
sortBy={sortBy}
page={page}
page={storePage}
totalPages={totalPages}
pageSize={urlSize}
filters={filters}
onViewModeChange={setViewMode}
onSortChange={setSortBy}
onPageChange={setPage}
onSortChange={(newSort) => {
setSortBy(newSort);
setUrlPage(1);
}}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
buildPageHref={buildHref}
/>
</div>
</div>
Expand Down
249 changes: 249 additions & 0 deletions src/components/PropertyPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
'use client';

import React, { useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { PAGE_SIZE_OPTIONS, type PageSize } from '@/hooks/usePaginationParams';

interface PropertyPaginationProps {
page: number;
totalPages: number;
totalResults: number;
pageSize: PageSize;
onPageChange: (page: number) => void;
onPageSizeChange: (size: PageSize) => void;
/** Optional: pre-built href for each page number (enables native link behaviour) */
buildHref?: (page: number) => string;
}

/**
* Full-featured pagination bar:
* - Page size selector (12 / 24 / 48)
* - Total count display
* - Previous / Next buttons
* - Numbered page buttons with ellipsis
* - Keyboard navigation (โ† โ†’, Home, End)
*/
export const PropertyPagination: React.FC<PropertyPaginationProps> = ({
page,
totalPages,
totalResults,
pageSize,
onPageChange,
onPageSizeChange,
buildHref,
}) => {
// โ”€โ”€ Keyboard navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Only fire when no input/textarea/select is focused
const tag = (document.activeElement?.tagName ?? '').toLowerCase();
if (['input', 'textarea', 'select'].includes(tag)) return;

if (e.key === 'ArrowLeft' && page > 1) {
e.preventDefault();
onPageChange(page - 1);
} else if (e.key === 'ArrowRight' && page < totalPages) {
e.preventDefault();
onPageChange(page + 1);
} else if (e.key === 'Home') {
e.preventDefault();
onPageChange(1);
} else if (e.key === 'End') {
e.preventDefault();
onPageChange(totalPages);
}
},
[page, totalPages, onPageChange],
);

useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);

// โ”€โ”€ Page number window โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const getPageNumbers = (): (number | 'ellipsis-start' | 'ellipsis-end')[] => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}

const pages: (number | 'ellipsis-start' | 'ellipsis-end')[] = [1];

if (page > 3) pages.push('ellipsis-start');

const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);

for (let i = start; i <= end; i++) pages.push(i);

if (page < totalPages - 2) pages.push('ellipsis-end');

pages.push(totalPages);
return pages;
};

const pageNumbers = getPageNumbers();

// โ”€โ”€ Range display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const rangeStart = Math.min((page - 1) * pageSize + 1, totalResults);
const rangeEnd = Math.min(page * pageSize, totalResults);

if (totalPages <= 1 && totalResults <= pageSize) return null;

return (
<nav
aria-label="Property listings pagination"
className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-10 pt-6 border-t border-gray-200 dark:border-gray-700"
>
{/* โ”€โ”€ Left: total count + page size selector โ”€โ”€ */}
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span aria-live="polite" aria-atomic="true">
Showing{' '}
<span className="font-semibold text-gray-900 dark:text-white">
{rangeStart}โ€“{rangeEnd}
</span>{' '}
of{' '}
<span className="font-semibold text-gray-900 dark:text-white">
{totalResults}
</span>{' '}
properties
</span>

<label className="flex items-center gap-2">
<span className="sr-only">Results per page</span>
<span className="hidden sm:inline">Per page:</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value) as PageSize)}
className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
aria-label="Results per page"
>
{PAGE_SIZE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>

{/* โ”€โ”€ Right: page controls โ”€โ”€ */}
<div className="flex items-center gap-1" role="group" aria-label="Page navigation">
{/* Previous */}
<PageButton
onClick={() => onPageChange(page - 1)}
href={buildHref ? buildHref(page - 1) : undefined}
disabled={page === 1}
aria-label="Go to previous page"
title="Previous page (โ†)"
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline ml-1">Previous</span>
</PageButton>

{/* Page numbers */}
{pageNumbers.map((p, idx) => {
if (p === 'ellipsis-start' || p === 'ellipsis-end') {
return (
<span
key={p}
className="w-10 h-10 flex items-center justify-center text-gray-400 dark:text-gray-500 select-none"
aria-hidden="true"
>
โ€ฆ
</span>
);
}

const isActive = p === page;
return (
<PageButton
key={p}
onClick={() => onPageChange(p)}
href={buildHref ? buildHref(p) : undefined}
active={isActive}
aria-label={`Go to page ${p}`}
aria-current={isActive ? 'page' : undefined}
title={`Page ${p}`}
>
{p}
</PageButton>
);
})}

{/* Next */}
<PageButton
onClick={() => onPageChange(page + 1)}
href={buildHref ? buildHref(page + 1) : undefined}
disabled={page === totalPages}
aria-label="Go to next page"
title="Next page (โ†’)"
>
<span className="hidden sm:inline mr-1">Next</span>
<ChevronRight className="w-4 h-4" />
</PageButton>
</div>
</nav>
);
};

// โ”€โ”€ Internal helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

interface PageButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
href?: string;
active?: boolean;
children: React.ReactNode;
}

const PageButton: React.FC<PageButtonProps> = ({
href,
active = false,
disabled = false,
children,
onClick,
...rest
}) => {
const base =
'inline-flex items-center justify-center min-w-[2.5rem] h-10 px-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500';
const activeClass = 'bg-blue-600 text-white shadow-sm';
const inactiveClass =
'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700';
const disabledClass = 'opacity-40 cursor-not-allowed pointer-events-none';

const className = [
base,
active ? activeClass : inactiveClass,
disabled ? disabledClass : '',
]
.filter(Boolean)
.join(' ');

// Use an <a> tag when href is provided so the browser can prefetch / open in new tab
if (href && !disabled) {
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
onClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>);
}}
className={className}
{...(rest as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{children}
</a>
);
}

return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={className}
{...rest}
>
{children}
</button>
);
};
Loading