diff --git a/app/builders/page.tsx b/app/builders/page.tsx index b6a77d5..4cd59e0 100644 --- a/app/builders/page.tsx +++ b/app/builders/page.tsx @@ -1,9 +1,21 @@ import { Suspense } from 'react'; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; import { BuildersView, BuildersViewFallback, } from '@/components/discover/builders-view'; +import { + BUILDERS_REVALIDATE_SECONDS, + buildersKeys, + fetchBuilderFilters, + fetchBuilders, + parseBuildersQueryParams, +} from '@/components/discover/use-builders'; import { SiteFooter } from '@/components/layout/site-footer'; import { SiteHeader } from '@/components/layout/site-header'; import { JsonLd } from '@/components/seo/json-ld'; @@ -33,16 +45,54 @@ const buildersBreadcrumbs = breadcrumbSchema([ { name: 'Builders', path: '/builders' }, ]); -export default function BuildersPage() { +interface BuildersPageProps { + searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function BuildersPage(props: BuildersPageProps) { + const resolvedSearchParams = props.searchParams + ? await props.searchParams + : {}; + const queryParams = parseBuildersQueryParams(resolvedSearchParams); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + }, + }, + }); + + await Promise.allSettled([ + queryClient.prefetchQuery({ + queryKey: buildersKeys.list(queryParams), + queryFn: () => + fetchBuilders(queryParams, { + next: { revalidate: BUILDERS_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), + }), + }), + queryClient.prefetchQuery({ + queryKey: buildersKeys.filters, + queryFn: () => + fetchBuilderFilters({ + next: { revalidate: BUILDERS_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), + }), + }), + ]); + return ( <> - }> - - + + }> + + + ); diff --git a/app/page.tsx b/app/page.tsx index 77e60fb..3b65373 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,21 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; import Link from 'next/link'; import { FeaturedProjects, + featuredProjectsQueryKey, + fetchFeaturedProjects, + fetchTopBuilders, + fetchTotal, + LANDING_REVALIDATE_SECONDS, + STATS, StatsStrip, TopBuilders, + topBuildersQueryKey, WantToBuild, } from '@/components/landing'; import { SiteFooter } from '@/components/layout/site-footer'; @@ -29,7 +41,44 @@ export const metadata: Metadata = { }, }; -export default function Home() { +export default async function Home() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + }, + }, + }); + + await Promise.allSettled([ + ...STATS.map((stat) => + queryClient.prefetchQuery({ + queryKey: stat.queryKey, + queryFn: () => + fetchTotal(stat.path, { + next: { revalidate: LANDING_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), + }), + }) + ), + queryClient.prefetchQuery({ + queryKey: topBuildersQueryKey, + queryFn: () => + fetchTopBuilders({ + next: { revalidate: LANDING_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), + }), + }), + queryClient.prefetchQuery({ + queryKey: featuredProjectsQueryKey, + queryFn: () => + fetchFeaturedProjects({ + next: { revalidate: LANDING_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), + }), + }), + ]); + return ( <> @@ -60,9 +109,11 @@ export default function Home() { - - - + + + + + diff --git a/components/discover/builders-view.tsx b/components/discover/builders-view.tsx index 7f9bbe1..14e8468 100644 --- a/components/discover/builders-view.tsx +++ b/components/discover/builders-view.tsx @@ -21,44 +21,20 @@ import { } from "./filter-rail"; import { FilterSheet } from "./filter-sheet"; import { + BUILDERS_PAGE_SIZE, + BUILDERS_SEARCH_PARAM, DEFAULT_BUILDER_SORT, - isBuilderSort, + parseBuildersQueryParams, + readBuilderFilters, + readBuilderPage, + readBuilderSort, useBuilderFilters, useBuilders, type BuilderSort, type BuildersQueryParams, } from "./use-builders"; -const PAGE_SIZE = 12; const SEARCH_DEBOUNCE_MS = 300; -const SEARCH_PARAM = "q"; - -/** - * The query string is the source of truth for everything that narrows the - * list, so a filtered view can be linked, bookmarked, and walked back through - * with the browser buttons. Defaults are left out to keep the URL readable. - */ -function readFilters(params: URLSearchParams): FilterValue { - const country = params.get("country"); - const status = params.get("status"); - return { - // Repeated `skills` params, matching what the endpoint accepts. Joining on - // a comma would break any facet whose own label contains one. - skills: params.getAll("skills"), - country: country ? [country] : [], - status: status ? [status] : [], - }; -} - -function readPage(params: URLSearchParams): number { - const raw = Number(params.get("page")); - return Number.isInteger(raw) && raw > 0 ? raw : 1; -} - -function readSort(params: URLSearchParams): BuilderSort { - const raw = params.get("sort"); - return raw && isBuilderSort(raw) ? raw : DEFAULT_BUILDER_SORT; -} function buildQuery( search: string, @@ -67,7 +43,7 @@ function buildQuery( sort: BuilderSort, ): string { const next = new URLSearchParams(); - if (search) next.set(SEARCH_PARAM, search); + if (search) next.set(BUILDERS_SEARCH_PARAM, search); for (const skill of filters.skills ?? []) next.append("skills", skill); if (filters.country?.[0]) next.set("country", filters.country[0]); if (filters.status?.[0]) next.set("status", filters.status[0]); @@ -102,10 +78,10 @@ export function BuildersView() { const router = useRouter(); const searchParams = useSearchParams(); - const search = searchParams.get(SEARCH_PARAM) ?? ""; - const filters = useMemo(() => readFilters(searchParams), [searchParams]); - const page = readPage(searchParams); - const sort = readSort(searchParams); + const search = searchParams.get(BUILDERS_SEARCH_PARAM) ?? ""; + const filters = useMemo(() => readBuilderFilters(searchParams), [searchParams]); + const page = readBuilderPage(searchParams); + const sort = readBuilderSort(searchParams); const [searchInput, setSearchInput] = useState(search); const [railOpen, setRailOpen] = useState(true); @@ -155,8 +131,8 @@ export function BuildersView() { // reverting the sort. Everything except the term is carried through // exactly as it stands at fire time. const next = new URLSearchParams(window.location.search); - if (trimmed) next.set(SEARCH_PARAM, trimmed); - else next.delete(SEARCH_PARAM); + if (trimmed) next.set(BUILDERS_SEARCH_PARAM, trimmed); + else next.delete(BUILDERS_SEARCH_PARAM); // Narrowing the list invalidates the page number. next.delete("page"); const query = next.toString(); @@ -176,18 +152,8 @@ export function BuildersView() { }; const params = useMemo( - () => ({ - 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(), }); }