-
-
-
- {displayName}
-
+ <>
+ {/* Header: avatar + name + username */}
+
+
+
+
+ {displayName}
+
+ {username && (
@{username}
-
+ )}
+
- {/* Variable content: role, location, skills — stretches to keep cards equal-height */}
-
- {/* Role / title */}
- {role && (
-
{role}
- )}
+ {/* Variable content: role, location, skills — stretches to keep cards equal-height */}
+
+ {/* Role / title */}
+ {role && (
+
{role}
+ )}
- {/* Location */}
- {location && (
-
- {location}
-
- )}
+ {/* Location */}
+ {location && (
+
+ {location}
+
+ )}
- {/* Skills */}
- {skills.length > 0 && (
-
- {skills.map((skill) => (
-
- {skill}
-
- ))}
-
- )}
-
+ {/* Skills */}
+ {skills.length > 0 && (
+
+ {skills.map((skill) => (
+
+ {skill}
+
+ ))}
+
+ )}
+
-
+ {showStats && (
+ <>
+
- {/* Follower / project counts */}
-
- {followers !== undefined && (
-
- {followers.toLocaleString()}{' '}
- {followers === 1 ? 'follower' : 'followers'}
-
- )}
- {projects !== undefined && (
-
- {projects.toLocaleString()}{' '}
- {projects === 1 ? 'project' : 'projects'}
-
- )}
-
+ {/* Follower / project counts */}
+
+ {followers !== undefined && (
+
+ {followers.toLocaleString()}{' '}
+ {followers === 1 ? 'follower' : 'followers'}
+
+ )}
+ {projects !== undefined && (
+
+ {projects.toLocaleString()}{' '}
+ {projects === 1 ? 'project' : 'projects'}
+
+ )}
+
+ >
+ )}
+ >
+ );
+}
+
+/** A single builder card in the discovery grid or /builders directory. */
+export function BuilderCard({
+ builder,
+}: {
+ builder: BuilderCardView;
+}) {
+ const { displayName, detailUrl } = builder;
+
+ const articleClassName = cn(
+ 'flex h-full min-w-0 flex-col gap-5 rounded-2xl border border-border bg-ink p-4',
+ // Hover lift belongs to the link wrapper; plain cards (no profile yet) stay static.
+ detailUrl &&
+ 'transition-[transform,border-color] duration-200 group-hover:border-[#2a3a37] motion-reduce:transition-none'
+ );
+
+ if (!detailUrl) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
);
diff --git a/components/cards/types.ts b/components/cards/types.ts
index e29efc3b..3c0997b7 100644
--- a/components/cards/types.ts
+++ b/components/cards/types.ts
@@ -30,8 +30,8 @@ export interface BuilderCardView {
followers?: number;
/** Project count when available. */
projects?: number;
- /** Profile page path so cards can link out. */
- detailUrl: string;
+ /** Profile page path so cards can link out. Absent when no profile target exists (e.g. a directory row with a null username). */
+ detailUrl?: string;
}
export interface OpportunityCardView {
diff --git a/components/discover/builders-grid.tsx b/components/discover/builders-grid.tsx
new file mode 100644
index 00000000..30ef89f6
--- /dev/null
+++ b/components/discover/builders-grid.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { BuilderCard } from '@/components/cards/builder-card';
+import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton';
+import { Pagination } from '@/components/ui/pagination';
+import type { Paginated } from '@/lib/api/types';
+
+import { toDirectoryBuilderCard } from './to-builder-directory-card';
+import type { BuilderListItemDto } from './use-builders';
+
+const GRID_CLASS = 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4';
+
+export function BuildersGrid({
+ data,
+ isPending,
+ isError,
+ isNarrowed,
+ page,
+ pageSize,
+ onPageChange,
+}: {
+ data?: Paginated
;
+ isPending: boolean;
+ isError: boolean;
+ /** A search or filter is applied, so an empty result is a miss, not an empty directory. */
+ isNarrowed: boolean;
+ page: number;
+ pageSize: number;
+ onPageChange: (page: number) => void;
+}) {
+ if (isPending) {
+ return (
+
+ {Array.from({ length: pageSize }, (_, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+ Builders could not be loaded right now.
+
+ );
+ }
+
+ if (data.data.length === 0) {
+ return (
+
+ {isNarrowed
+ ? 'No builders match these filters.'
+ : 'No builders to show yet.'}
+
+ );
+ }
+
+ return (
+
+
+ {data.data.map((builder) => (
+
+ ))}
+
+
+ {data.pagination.total > 0 && (
+
+ )}
+
+ );
+}
diff --git a/components/discover/builders-view.tsx b/components/discover/builders-view.tsx
new file mode 100644
index 00000000..e73ccf28
--- /dev/null
+++ b/components/discover/builders-view.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+
+import { Section } from '@/components/marketing/section';
+
+import { BuildersGrid } from './builders-grid';
+import { DiscoverHeader } from './discover-header';
+import { DiscoverToolbar } from './discover-toolbar';
+import {
+ useBuilders,
+ type BuildersQueryParams,
+} from './use-builders';
+
+const PAGE_SIZE = 12;
+const SEARCH_DEBOUNCE_MS = 300;
+
+/**
+ * The `/builders` directory. Owns the search, filter, and page state and feeds
+ * it to `useBuilders`, so the controls and the grid read from one source.
+ * Modelled on `projects-view.tsx`. Filters and sort are out of scope for this
+ * issue and will land in follow-up PRs.
+ */
+export function BuildersView() {
+ const [searchInput, setSearchInput] = useState('');
+ const [search, setSearch] = useState('');
+ const [page, setPage] = useState(1);
+
+ // Keep typing off the network until the visitor pauses. Narrowing the results
+ // invalidates the page number, so the debounce resets it too.
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ // Trim before it reaches the query: a spaces-only box is an empty search,
+ // not a search that matches nothing.
+ setSearch(searchInput.trim());
+ setPage(1);
+ }, SEARCH_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [searchInput]);
+
+ const params = useMemo(
+ () => ({
+ page,
+ limit: PAGE_SIZE,
+ search: search || undefined,
+ }),
+ [page, search]
+ );
+
+ const { data, isError, isPending } = useBuilders(params);
+
+ return (
+
+
+
+ {/* Filters and sort land in follow-up issues (#366, #367), so only the
+ functional controls are wired here; the toolbar hides the rest. */}
+
+
+ 0}
+ page={page}
+ pageSize={PAGE_SIZE}
+ onPageChange={setPage}
+ />
+
+ );
+}
diff --git a/components/discover/discover-toolbar.tsx b/components/discover/discover-toolbar.tsx
index 1882fa15..714c4ffb 100644
--- a/components/discover/discover-toolbar.tsx
+++ b/components/discover/discover-toolbar.tsx
@@ -13,45 +13,72 @@ import { AnimatePresence, motion } from 'motion/react';
import { Button } from '@/components/ui/button';
import { transitions } from '@/lib/motion';
+/**
+ * Filter wiring is all or nothing. A page either owns filter state and passes
+ * every handler, or it passes none and the toolbar shows search alone. Making
+ * this a union means a half-wired caller is a type error rather than a toolbar
+ * that quietly drops its filter controls.
+ */
+type FilterProps =
+ | {
+ filtersOpen: boolean;
+ onToggleFilters: () => void;
+ onOpenMobileFilters: () => void;
+ filtersActive: boolean;
+ onReset: () => void;
+ }
+ | {
+ filtersOpen?: never;
+ onToggleFilters?: never;
+ onOpenMobileFilters?: never;
+ filtersActive?: never;
+ onReset?: never;
+ };
+
export function DiscoverToolbar({
- filtersOpen,
+ filtersOpen = false,
onToggleFilters,
onOpenMobileFilters,
- filtersActive,
+ filtersActive = false,
onReset,
+ showSort = true,
query,
onQueryChange,
placeholder = 'Search',
-}: {
- filtersOpen: boolean;
- onToggleFilters: () => void;
- onOpenMobileFilters: () => void;
- filtersActive: boolean;
- onReset: () => void;
+}: FilterProps & {
+ /** Hide the sort pill on pages that don't wire sorting yet (e.g. `/builders`). */
+ showSort?: boolean;
query: string;
onQueryChange: (value: string) => void;
placeholder?: string;
}) {
+ // Pages like `/builders` only wire search today, so they omit the handlers and
+ // the toolbar shows search alone instead of a Filters button that does nothing.
+ // `FilterProps` guarantees these arrive together, so one check covers all three.
+ const showFilters = onToggleFilters !== undefined;
+
return (
-
+ {showFilters && (
+
+ )}
- {filtersActive ? (
+ {showFilters && filtersActive ? (
-
+ {showFilters && (
+
+ )}
- {filtersActive ? (
+ {showFilters && filtersActive ? (
-
+ {showSort && (
+
+ )}
);
}
diff --git a/components/discover/to-builder-directory-card.ts b/components/discover/to-builder-directory-card.ts
new file mode 100644
index 00000000..835c8add
--- /dev/null
+++ b/components/discover/to-builder-directory-card.ts
@@ -0,0 +1,30 @@
+import type { BuilderCardView } from '@/components/cards/types';
+
+import type { BuilderListItemDto } from './use-builders';
+
+const MAX_SKILLS = 4;
+
+/**
+ * Map a `/users/directory` row to the card view model. This is intentionally
+ * separate from `to-builder-card.ts` (which maps `/users/top-builders` rows)
+ * because the DTOs differ: the directory row has nullable name/username, no
+ * followers/projects counts, and extra fields like bio, status, and joinedAt.
+ */
+export function toDirectoryBuilderCard(
+ builder: BuilderListItemDto
+): BuilderCardView {
+ return {
+ id: builder.id,
+ displayName: builder.name ?? builder.username ?? 'Unknown',
+ username: builder.username ?? '',
+ avatarSrc: builder.image ?? undefined,
+ location: builder.location ?? builder.country ?? undefined,
+ skills: builder.skills?.slice(0, MAX_SKILLS),
+ // Only link out when a username exists; a null username would otherwise
+ // produce a `/builders/null` href. Cards without a target render as plain
+ // (non-clickable) cards.
+ detailUrl: builder.username
+ ? `/builders/${builder.username}`
+ : undefined,
+ };
+}
diff --git a/components/discover/use-builders.ts b/components/discover/use-builders.ts
new file mode 100644
index 00000000..15f1dee4
--- /dev/null
+++ b/components/discover/use-builders.ts
@@ -0,0 +1,58 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { apiFetch } from '@/lib/api/client';
+import type { Paginated, Schemas } from '@/lib/api/types';
+
+/**
+ * `/users/directory` list item. Derived from the generated OpenAPI schema.
+ * The DTO has no `followers` or `projects` fields, so the directory card
+ * renders without those counts (see the open decision note in the PR).
+ */
+export type BuilderListItemDto = Schemas['BuilderListItemDto'];
+
+/** Query params `GET /users/directory` accepts from the directory page. */
+export interface BuildersQueryParams {
+ page?: number;
+ limit?: number;
+ search?: string;
+ country?: string;
+ skills?: string[];
+ status?: string;
+ sort?: string;
+}
+
+export const buildersKeys = {
+ all: ['builders'] as const,
+ list: (params: BuildersQueryParams) =>
+ ['builders', 'list', params] as const,
+};
+
+function toQueryString(params: BuildersQueryParams): string {
+ const search = new URLSearchParams();
+
+ if (params.page) search.set('page', String(params.page));
+ if (params.limit) search.set('limit', String(params.limit));
+ if (params.search) search.set('search', params.search);
+ if (params.country) search.set('country', params.country);
+ if (params.skills?.length) {
+ for (const skill of params.skills) {
+ search.append('skills', skill);
+ }
+ }
+ if (params.status) search.set('status', params.status);
+ if (params.sort) search.set('sort', params.sort);
+
+ const query = search.toString();
+ return query ? `?${query}` : '';
+}
+
+/** Paginated `/users/directory` list, filtered by the params above. */
+export function useBuilders(params: BuildersQueryParams = {}) {
+ return useQuery({
+ queryKey: buildersKeys.list(params),
+ queryFn: () =>
+ apiFetch