(
- () => ({
- page,
- limit: PAGE_SIZE,
- search: search || undefined,
- // `GET /users/directory` takes a single value for these two, so their
- // rail sections are single-select radios. Only `skills` is repeatable.
- skills: filters.skills?.length ? filters.skills : undefined,
- country: filters.country?.[0],
- status: filters.status?.[0],
- sort,
- }),
- [page, search, filters, sort],
+ () => parseBuildersQueryParams(searchParams),
+ [searchParams]
);
const { data, isError, isPending } = useBuilders(params);
@@ -294,7 +260,7 @@ export function BuildersView() {
isError={isError}
isNarrowed={hasActiveFilters(filters) || search.length > 0}
page={page}
- pageSize={PAGE_SIZE}
+ pageSize={BUILDERS_PAGE_SIZE}
onPageChange={(next) => navigate(search, filters, next, "push")}
onHelpClick={() => setHelpDialogOpen(true)}
/>
@@ -336,7 +302,7 @@ export function BuildersViewFallback() {
subtitle="Meet the builders making an impact across the Boundless ecosystem."
/>
- {Array.from({ length: PAGE_SIZE }, (_, index) => (
+ {Array.from({ length: BUILDERS_PAGE_SIZE }, (_, index) => (
))}
diff --git a/components/discover/use-builders.ts b/components/discover/use-builders.ts
index 777cd6a..ce41b9b 100644
--- a/components/discover/use-builders.ts
+++ b/components/discover/use-builders.ts
@@ -95,14 +95,100 @@ function toQueryString(params: BuildersQueryParams): string {
return query ? `?${query}` : '';
}
+export const BUILDERS_REVALIDATE_SECONDS = 60;
+export const BUILDERS_PAGE_SIZE = 12;
+export const BUILDERS_SEARCH_PARAM = 'q';
+
+export type BuilderFilterValue = Record & {
+ skills: string[];
+ country: string[];
+ status: string[];
+};
+
+export function readBuilderFilters(params: URLSearchParams): BuilderFilterValue {
+ const country = params.get('country');
+ const status = params.get('status');
+ return {
+ skills: params.getAll('skills'),
+ country: country ? [country] : [],
+ status: status ? [status] : [],
+ };
+}
+
+export function readBuilderPage(params: URLSearchParams): number {
+ const raw = Number(params.get('page'));
+ return Number.isInteger(raw) && raw > 0 ? raw : 1;
+}
+
+export function readBuilderSort(params: URLSearchParams): BuilderSort {
+ const raw = params.get('sort');
+ return raw && isBuilderSort(raw) ? raw : DEFAULT_BUILDER_SORT;
+}
+
+export function toUrlSearchParams(
+ raw: Record
+): URLSearchParams {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(raw)) {
+ if (value === undefined) continue;
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ params.append(key, item);
+ }
+ } else {
+ params.set(key, value);
+ }
+ }
+ return params;
+}
+
+export function parseBuildersQueryParams(
+ searchParams:
+ | URLSearchParams
+ | Record
+): BuildersQueryParams {
+ const params =
+ searchParams instanceof URLSearchParams
+ ? searchParams
+ : toUrlSearchParams(searchParams);
+
+ const search = params.get(BUILDERS_SEARCH_PARAM) ?? '';
+ const filters = readBuilderFilters(params);
+ const page = readBuilderPage(params);
+ const sort = readBuilderSort(params);
+
+ return {
+ page,
+ limit: BUILDERS_PAGE_SIZE,
+ search: search || undefined,
+ skills: filters.skills?.length ? filters.skills : undefined,
+ country: filters.country?.[0],
+ status: filters.status?.[0],
+ sort,
+ };
+}
+
+export function fetchBuilders(
+ params: BuildersQueryParams = {},
+ init?: RequestInit
+): Promise {
+ return apiFetch(
+ `/users/directory${toQueryString(params)}`,
+ init
+ );
+}
+
+export function fetchBuilderFilters(
+ init?: RequestInit
+): Promise {
+ return apiFetch('/users/filters', init);
+}
+
/** Paginated `/users/directory` list, filtered by the params above. */
export function useBuilders(params: BuildersQueryParams = {}) {
return useQuery({
queryKey: buildersKeys.list(params),
- queryFn: () =>
- apiFetch(
- `/users/directory${toQueryString(params)}`
- ),
+ queryFn: () => fetchBuilders(params),
});
}
@@ -110,6 +196,7 @@ export function useBuilders(params: BuildersQueryParams = {}) {
export function useBuilderFilters() {
return useQuery({
queryKey: buildersKeys.filters,
- queryFn: () => apiFetch('/users/filters'),
+ queryFn: () => fetchBuilderFilters(),
});
}
+
diff --git a/components/landing/featured-projects.tsx b/components/landing/featured-projects.tsx
index a2faa73..907818b 100644
--- a/components/landing/featured-projects.tsx
+++ b/components/landing/featured-projects.tsx
@@ -8,19 +8,20 @@ import { OpportunityCard } from '@/components/cards/opportunity-card';
import { ArrowRightIcon } from '@/components/icons';
import { Section, SectionHeading } from '@/components/marketing/section';
import { Button } from '@/components/ui/button';
-import { apiFetch } from '@/lib/api/client';
-import type { Schemas } from '@/lib/api/types';
import { mainAppUrl } from '@/lib/main-app';
+import {
+ featuredProjectsQueryKey,
+ fetchFeaturedProjects,
+} from './landing-queries';
import { toProjectCard } from './to-project-card';
const FEATURED_PROJECTS_SKELETON_COUNT = 3;
function useFeaturedProjects() {
return useQuery({
- queryKey: ['projects', 'featured'],
- queryFn: () =>
- apiFetch('/projects/featured'),
+ queryKey: featuredProjectsQueryKey,
+ queryFn: () => fetchFeaturedProjects(),
});
}
diff --git a/components/landing/index.ts b/components/landing/index.ts
index a19c379..466a318 100644
--- a/components/landing/index.ts
+++ b/components/landing/index.ts
@@ -2,3 +2,5 @@ export { StatsStrip } from './stats-strip';
export { TopBuilders } from './top-builders';
export { FeaturedProjects } from './featured-projects';
export { WantToBuild } from './want-to-build';
+export * from './landing-queries';
+
diff --git a/components/landing/landing-queries.ts b/components/landing/landing-queries.ts
new file mode 100644
index 0000000..9c8994b
--- /dev/null
+++ b/components/landing/landing-queries.ts
@@ -0,0 +1,59 @@
+import { apiFetch } from '@/lib/api/client';
+import type { Schemas } from '@/lib/api/types';
+import type { TopBuilder } from './to-builder-card';
+
+/**
+ * Cache lifetime in seconds for the landing page data.
+ * Keeps API load low while revalidating periodically.
+ */
+export const LANDING_REVALIDATE_SECONDS = 180;
+
+export const STATS = [
+ {
+ label: 'Builders',
+ path: '/users/directory?limit=1',
+ queryKey: ['ecosystem-stats', 'builders'] as const,
+ },
+ {
+ label: 'Projects',
+ path: '/projects?limit=1',
+ queryKey: ['ecosystem-stats', 'projects'] as const,
+ },
+] as const;
+
+/** Every list the strip counts documents the same `pagination` block. */
+export type CountedList =
+ | Schemas['BuildersDirectoryResponseDto']
+ | Schemas['PublicProjectListDto'];
+
+export async function fetchTotal(
+ path: string,
+ init?: RequestInit
+): Promise {
+ const response = await apiFetch(path, init);
+ return response.pagination.total;
+}
+
+export const TOP_BUILDERS_LIMIT = 8;
+export const topBuildersQueryKey = [
+ 'users',
+ 'top-builders',
+ TOP_BUILDERS_LIMIT,
+] as const;
+
+export async function fetchTopBuilders(
+ init?: RequestInit
+): Promise {
+ return apiFetch(
+ `/users/top-builders?limit=${TOP_BUILDERS_LIMIT}`,
+ init
+ );
+}
+
+export const featuredProjectsQueryKey = ['projects', 'featured'] as const;
+
+export async function fetchFeaturedProjects(
+ init?: RequestInit
+): Promise {
+ return apiFetch('/projects/featured', init);
+}
diff --git a/components/landing/stats-strip.tsx b/components/landing/stats-strip.tsx
index 51ca8b4..d48ed95 100644
--- a/components/landing/stats-strip.tsx
+++ b/components/landing/stats-strip.tsx
@@ -6,21 +6,8 @@ import { Section } from '@/components/marketing/section';
import { CountUp } from '@/components/ui/count-up';
import { Skeleton } from '@/components/ui/skeleton';
import { StatsBar, type StatItem } from '@/components/ui/stats-bar';
-import { apiFetch } from '@/lib/api/client';
-import type { Schemas } from '@/lib/api/types';
-const STATS = [
- {
- label: 'Builders',
- path: '/users/directory?limit=1',
- queryKey: ['ecosystem-stats', 'builders'],
- },
- {
- label: 'Projects',
- path: '/projects?limit=1',
- queryKey: ['ecosystem-stats', 'projects'],
- },
-] as const;
+import { STATS, fetchTotal } from './landing-queries';
function NumberSkeleton({ mobile = false }: { mobile?: boolean }) {
return (
@@ -31,16 +18,6 @@ function NumberSkeleton({ mobile = false }: { mobile?: boolean }) {
);
}
-/** Every list the strip counts documents the same `pagination` block. */
-type CountedList =
- | Schemas['BuildersDirectoryResponseDto']
- | Schemas['PublicProjectListDto'];
-
-async function fetchTotal(path: string) {
- const response = await apiFetch(path);
- return response.pagination.total;
-}
-
function useEcosystemStats() {
return useQueries({
queries: STATS.map(stat => ({
diff --git a/components/landing/top-builders.tsx b/components/landing/top-builders.tsx
index 32d391f..e4853b3 100644
--- a/components/landing/top-builders.tsx
+++ b/components/landing/top-builders.tsx
@@ -8,17 +8,18 @@ import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton';
import { ArrowRightIcon } from '@/components/icons';
import { Section, SectionHeading } from '@/components/marketing/section';
import { Button } from '@/components/ui/button';
-import { apiFetch } from '@/lib/api/client';
-import { type TopBuilder, toBuilderCard } from './to-builder-card';
-
-const TOP_BUILDERS_LIMIT = 8;
+import {
+ TOP_BUILDERS_LIMIT,
+ topBuildersQueryKey,
+ fetchTopBuilders,
+} from './landing-queries';
+import { toBuilderCard } from './to-builder-card';
function useTopBuilders() {
return useQuery({
- queryKey: ['users', 'top-builders', TOP_BUILDERS_LIMIT],
- queryFn: () =>
- apiFetch(`/users/top-builders?limit=${TOP_BUILDERS_LIMIT}`),
+ queryKey: topBuildersQueryKey,
+ queryFn: () => fetchTopBuilders(),
});
}