diff --git a/app/builders/page.tsx b/app/builders/page.tsx new file mode 100644 index 00000000..22c23ff6 --- /dev/null +++ b/app/builders/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next'; + +import { BuildersView } from '@/components/discover/builders-view'; +import { SiteFooter } from '@/components/layout/site-footer'; +import { SiteHeader } from '@/components/layout/site-header'; + +export const metadata: Metadata = { + title: 'Builders', + description: + 'Discover the builders shipping across the Boundless ecosystem.', +}; + +export default function BuildersPage() { + return ( + <> + + + + + ); +} diff --git a/components/cards/builder-card-skeleton.tsx b/components/cards/builder-card-skeleton.tsx index e3480d23..eb5d9c73 100644 --- a/components/cards/builder-card-skeleton.tsx +++ b/components/cards/builder-card-skeleton.tsx @@ -1,7 +1,15 @@ import { Skeleton } from '@/components/ui/skeleton'; -/** Loading placeholder that mirrors the BuilderCard layout. */ -export function BuilderCardSkeleton() { +/** + * Loading placeholder that mirrors the BuilderCard layout. `showStats` must + * match the card it stands in for: the directory omits follower and project + * counts, so reserving room for them here makes the grid jump on load. + */ +export function BuilderCardSkeleton({ + showStats = true, +}: { + showStats?: boolean; +} = {}) { return (
@@ -22,12 +30,16 @@ export function BuilderCardSkeleton() {
- + {showStats && ( + <> + -
- - -
+
+ + +
+ + )}
); } diff --git a/components/cards/builder-card.tsx b/components/cards/builder-card.tsx index 557292dc..4d7267e7 100644 --- a/components/cards/builder-card.tsx +++ b/components/cards/builder-card.tsx @@ -30,8 +30,7 @@ function Meta({ ); } -/** A single builder card in the discovery grid or /builders directory. */ -export function BuilderCard({ +function BuilderCardBody({ builder, }: { builder: BuilderCardView; @@ -45,82 +44,120 @@ export function BuilderCard({ skills: rawSkills, followers, projects, - detailUrl, } = builder; const skills = rawSkills ?? []; + // Directory rows have no follower/project counts, so the footer (and its + // divider) is skipped entirely rather than rendering a stray rule. + const showStats = followers !== undefined || projects !== undefined; return ( - -
- {/* Header: avatar + name + username */} -
- -
-

- {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>( + `/users/directory${toQueryString(params)}` + ), + }); +} diff --git a/lib/api/types.ts b/lib/api/types.ts index 4270ab2b..2cdd3af8 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -12,7 +12,7 @@ export type { components, paths }; /** * Every named schema (DTO) from the backend OpenAPI document. Derive resource * types from here rather than hand-writing them, e.g. - * `type BuilderListItem = Schemas['UserDirectoryItemDto']`. Regenerate with + * `type BuilderListItem = Schemas['BuilderListItemDto']`. Regenerate with * `npm run codegen` (live backend) or `npm run codegen:snapshot` (committed doc). */ export type Schemas = components['schemas'];