diff --git a/components/cards/opportunity-card.tsx b/components/cards/opportunity-card.tsx index e0fcf045..cf152ce3 100644 --- a/components/cards/opportunity-card.tsx +++ b/components/cards/opportunity-card.tsx @@ -10,6 +10,7 @@ import Image from 'next/image'; import Link from 'next/link'; import type { ReactNode } from 'react'; +import { deriveInitials } from '@/lib/initials'; import { cn } from '@/lib/utils'; import type { OpportunityCardStatus, OpportunityCardView } from './types'; @@ -81,96 +82,116 @@ export function OpportunityCard({ participants, mode, comments, + logo, endsIn, reward, detailUrl, } = opportunity; - return ( - -
-
-
+
+
+ {logo ? ( {org} - - {org} + ) : ( + + {deriveInitials(title)} - - #{index} - -
- + )} + + {org} + + + #{index} +
+ +
-

- {title} -

+

+ {title} +

- {/* Single row: short items hold their size, the mode label truncates. */} -
- - - - {category} - + {/* Single row: short items hold their size, the mode label truncates. */} +
+ + + + {category} - - {participants} - - - {mode} - - - {comments} - -
+
+ + {participants} + + + {mode} + + + {comments} + +
- {endsIn || reward ? ( - <> - + {endsIn || reward ? ( + <> + -
- {endsIn ? ( - - - Ends in {endsIn} - - ) : null} - {reward ? ( - - Token - - {reward.amount.toLocaleString()} {reward.currency} - +
+ {endsIn ? ( + + + Ends in {endsIn} + + ) : null} + {reward ? ( + + Token + + {reward.amount.toLocaleString()} {reward.currency} - ) : null} -
- - ) : null} -
+ + ) : null} + + + ) : null} + + ); + + if (!detailUrl) return card; + + return ( + + {card} ); } diff --git a/components/cards/types.ts b/components/cards/types.ts index 3c0997b7..05d4df32 100644 --- a/components/cards/types.ts +++ b/components/cards/types.ts @@ -46,10 +46,12 @@ export interface OpportunityCardView { /** Sub-mode label or pillar name. Empty string when nothing better is available. */ mode: string; comments: number; + /** Optional project logo image URL. */ + logo?: string; /** Pre-formatted countdown like `4D:22H:49M` when a deadline is available. */ endsIn?: string; reward?: { amount: number; currency: string }; - /** Detail page path so cards can link out. */ - detailUrl: string; + /** Detail page URL so cards can link out. Absent when there is no target. */ + detailUrl?: string; isFeatured: boolean; } diff --git a/components/discover/builders-grid.tsx b/components/discover/builders-grid.tsx index 16a2e789..cde38c57 100644 --- a/components/discover/builders-grid.tsx +++ b/components/discover/builders-grid.tsx @@ -1,13 +1,13 @@ -'use client'; +"use client"; -import { BuilderCard } from '@/components/cards/builder-card'; -import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton'; -import { Pagination } from '@/components/ui/pagination'; +import { BuilderCard } from "@/components/cards/builder-card"; +import { BuilderCardSkeleton } from "@/components/cards/builder-card-skeleton"; +import { Pagination } from "@/components/ui/pagination"; -import { toDirectoryBuilderCard } from './to-builder-directory-card'; -import type { BuildersDirectoryResponse } from './use-builders'; +import { toDirectoryBuilderCard } from "./to-builder-directory-card"; +import type { BuildersDirectoryResponse } from "./use-builders"; -const GRID_CLASS = 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4'; +const GRID_CLASS = "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"; export function BuildersGrid({ data, @@ -17,6 +17,7 @@ export function BuildersGrid({ page, pageSize, onPageChange, + onHelpClick, }: { data?: BuildersDirectoryResponse; isPending: boolean; @@ -26,6 +27,8 @@ export function BuildersGrid({ page: number; pageSize: number; onPageChange: (page: number) => void; + /** Called when the user clicks "How to get listed" in the empty state. */ + onHelpClick?: () => void; }) { if (isPending) { return ( @@ -39,7 +42,7 @@ export function BuildersGrid({ if (isError || !data) { return ( -

+

Builders could not be loaded right now.

); @@ -47,16 +50,26 @@ export function BuildersGrid({ if (data.data.length === 0) { return ( -

- {isNarrowed - ? 'No builders match these filters.' - : 'No builders to show yet.'} -

+
+

+ {isNarrowed + ? "No builders match these filters." + : "Builders appear here once their profile is complete and they have shipped work on Boundless."} +

+ {!isNarrowed && onHelpClick && ( + + )} +
); } return ( -
+
{data.data.map((builder) => ( )}
diff --git a/components/discover/builders-view.tsx b/components/discover/builders-view.tsx index c09084d8..7f9bbe10 100644 --- a/components/discover/builders-view.tsx +++ b/components/discover/builders-view.tsx @@ -1,24 +1,25 @@ -'use client'; +"use client"; -import { getCountryDataList } from 'countries-list'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { getCountryDataList } from "countries-list"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton'; -import { Activity01Icon, CodeIcon, GlobeIcon } from '@/components/icons'; -import { Section } from '@/components/marketing/section'; +import { BuilderCardSkeleton } from "@/components/cards/builder-card-skeleton"; +import { Activity01Icon, CodeIcon, GlobeIcon } from "@/components/icons"; +import { HowToGetListedDialog } from "@/components/discover/how-to-get-listed-dialog"; +import { Section } from "@/components/marketing/section"; -import { BuildersGrid } from './builders-grid'; -import { BuildersSortSelect } from './builders-sort-select'; -import { StatsBanner } from './stats-banner'; -import { DiscoverToolbar } from './discover-toolbar'; +import { BuildersGrid } from "./builders-grid"; +import { BuildersSortSelect } from "./builders-sort-select"; +import { StatsBanner } from "./stats-banner"; +import { DiscoverToolbar } from "./discover-toolbar"; import { FilterRail, hasActiveFilters, type FilterSectionConfig, type FilterValue, -} from './filter-rail'; -import { FilterSheet } from './filter-sheet'; +} from "./filter-rail"; +import { FilterSheet } from "./filter-sheet"; import { DEFAULT_BUILDER_SORT, isBuilderSort, @@ -26,11 +27,11 @@ import { useBuilders, type BuilderSort, type BuildersQueryParams, -} from './use-builders'; +} from "./use-builders"; const PAGE_SIZE = 12; const SEARCH_DEBOUNCE_MS = 300; -const SEARCH_PARAM = 'q'; +const SEARCH_PARAM = "q"; /** * The query string is the source of truth for everything that narrows the @@ -38,24 +39,24 @@ const SEARCH_PARAM = 'q'; * 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'); + 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'), + skills: params.getAll("skills"), country: country ? [country] : [], status: status ? [status] : [], }; } function readPage(params: URLSearchParams): number { - const raw = Number(params.get('page')); + const raw = Number(params.get("page")); return Number.isInteger(raw) && raw > 0 ? raw : 1; } function readSort(params: URLSearchParams): BuilderSort { - const raw = params.get('sort'); + const raw = params.get("sort"); return raw && isBuilderSort(raw) ? raw : DEFAULT_BUILDER_SORT; } @@ -63,15 +64,15 @@ function buildQuery( search: string, filters: FilterValue, page: number, - sort: BuilderSort + sort: BuilderSort, ): string { const next = new URLSearchParams(); if (search) next.set(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]); - if (page > 1) next.set('page', String(page)); - if (sort !== DEFAULT_BUILDER_SORT) next.set('sort', sort); + 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]); + if (page > 1) next.set("page", String(page)); + if (sort !== DEFAULT_BUILDER_SORT) next.set("sort", sort); return next.toString(); } @@ -83,7 +84,7 @@ const EMPTY_FILTERS: FilterValue = { /** ISO alpha-2 code -> country name, for friendlier facet labels. */ const COUNTRY_NAMES = new Map( - getCountryDataList().map(country => [country.iso2, country.name]) + getCountryDataList().map((country) => [country.iso2, country.name]), ); function countryLabel(code: string): string | undefined { @@ -101,7 +102,7 @@ export function BuildersView() { const router = useRouter(); const searchParams = useSearchParams(); - const search = searchParams.get(SEARCH_PARAM) ?? ''; + const search = searchParams.get(SEARCH_PARAM) ?? ""; const filters = useMemo(() => readFilters(searchParams), [searchParams]); const page = readPage(searchParams); const sort = readSort(searchParams); @@ -109,6 +110,7 @@ export function BuildersView() { const [searchInput, setSearchInput] = useState(search); const [railOpen, setRailOpen] = useState(true); const [sheetOpen, setSheetOpen] = useState(false); + const [helpDialogOpen, setHelpDialogOpen] = useState(false); // The box is the one control not driven straight from the URL, because typing // has to stay off the network until the visitor pauses. `committed` remembers @@ -129,16 +131,16 @@ export function BuildersView() { nextSearch: string, nextFilters: FilterValue, nextPage: number, - mode: 'push' | 'replace', - nextSort: BuilderSort = sort + mode: "push" | "replace", + nextSort: BuilderSort = sort, ) => { const query = buildQuery(nextSearch, nextFilters, nextPage, nextSort); - const href = query ? `/builders?${query}` : '/builders'; + const href = query ? `/builders?${query}` : "/builders"; // `scroll: false` so narrowing the list does not yank the page to the top. - if (mode === 'replace') router.replace(href, { scroll: false }); + if (mode === "replace") router.replace(href, { scroll: false }); else router.push(href, { scroll: false }); }, - [router, sort] + [router, sort], ); // Search uses replace so a debounced keystroke does not add a history entry. @@ -156,9 +158,9 @@ export function BuildersView() { if (trimmed) next.set(SEARCH_PARAM, trimmed); else next.delete(SEARCH_PARAM); // Narrowing the list invalidates the page number. - next.delete('page'); + next.delete("page"); const query = next.toString(); - router.replace(query ? `/builders?${query}` : '/builders', { + router.replace(query ? `/builders?${query}` : "/builders", { scroll: false, }); }, SEARCH_DEBOUNCE_MS); @@ -166,11 +168,11 @@ export function BuildersView() { }, [searchInput, search, router]); const applyFilters = (next: FilterValue) => { - navigate(search, next, 1, 'push'); + navigate(search, next, 1, "push"); }; const applySort = (next: BuilderSort) => { - navigate(search, filters, 1, 'push', next); + navigate(search, filters, 1, "push", next); }; const params = useMemo( @@ -185,7 +187,7 @@ export function BuildersView() { status: filters.status?.[0], sort, }), - [page, search, filters, sort] + [page, search, filters, sort], ); const { data, isError, isPending } = useBuilders(params); @@ -199,39 +201,39 @@ export function BuildersView() { if (!facets) return []; return [ { - group: 'skills', - selection: 'multi', - title: 'Skills', + group: "skills", + selection: "multi", + title: "Skills", icon: CodeIcon, - kind: 'facet', + kind: "facet", items: facets.skills, }, { - group: 'country', - selection: 'single', - title: 'Country', + group: "country", + selection: "single", + title: "Country", icon: GlobeIcon, - kind: 'facet', - items: facets.countries.map(item => ({ + kind: "facet", + items: facets.countries.map((item) => ({ ...item, label: countryLabel(item.value) ?? item.value, })), }, { - group: 'status', - selection: 'single', - title: 'Status', + group: "status", + selection: "single", + title: "Status", icon: Activity01Icon, - kind: 'enum', + kind: "enum", items: facets.statuses, }, ]; }, [facets]); const reset = () => { - setCommitted(''); - setSearchInput(''); - navigate('', EMPTY_FILTERS, 1, 'push'); + setCommitted(""); + setSearchInput(""); + navigate("", EMPTY_FILTERS, 1, "push"); }; // A shared link can name a page that no longer exists (the directory shrank, @@ -240,37 +242,42 @@ export function BuildersView() { const totalPages = data?.pagination.totalPages ?? 0; useEffect(() => { if (totalPages > 0 && page > totalPages) { - navigate(search, filters, totalPages, 'replace'); + navigate(search, filters, totalPages, "replace"); } }, [page, totalPages, search, filters, sort, navigate]); return (
setHelpDialogOpen(true), + }} /> setRailOpen(open => !open)} + onToggleFilters={() => setRailOpen((open) => !open)} onOpenMobileFilters={() => setSheetOpen(true)} filtersActive={hasActiveFilters(filters)} onReset={reset} query={searchInput} onQueryChange={setSearchInput} - placeholder='Search builders, skills, or locations' + placeholder="Search builders, skills, or locations" sort={} /> -
+
{railOpen ? ( -
); } @@ -319,14 +328,14 @@ export function BuildersView() { export function BuildersViewFallback() { return (
-
+
{Array.from({ length: PAGE_SIZE }, (_, index) => ( ))} diff --git a/components/discover/how-to-get-listed-dialog.tsx b/components/discover/how-to-get-listed-dialog.tsx new file mode 100644 index 00000000..861c7a03 --- /dev/null +++ b/components/discover/how-to-get-listed-dialog.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { mainAppUrl } from "@/lib/main-app"; + +/** + * Dialog explaining how builders get listed in the directory. Triggered from + * the stats banner header or the empty state message. Lists the three criteria + * (complete profile, public profile, real work) and provides a link to the + * main app's profile settings. + */ +export function HowToGetListedDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + How to get listed + + Builders appear in the directory when they meet these requirements: + + + +
+
+
+ 1 +
+

+ Complete profile with a username, photo, and at least one skill +

+
+ +
+
+ 2 +
+

Public profile

+
+ +
+
+ 3 +
+

+ Real work on Boundless: a published project you created or joined, + a hackathon entry, a bounty submission, or a bounty, quest, or + grant award +

+
+
+ +
+ + + + +
+
+
+ ); +} diff --git a/components/discover/stats-banner.tsx b/components/discover/stats-banner.tsx index 81f988ec..7bb2d295 100644 --- a/components/discover/stats-banner.tsx +++ b/components/discover/stats-banner.tsx @@ -1,4 +1,4 @@ -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils"; export interface StatsBannerStat { label: string; @@ -14,6 +14,12 @@ export interface StatsBannerProps { */ stats?: StatsBannerStat[]; className?: string; + /** Optional subtitle with a link, e.g. for "How to get listed". */ + subtitleWithLink?: { + text: string; + linkText: string; + onLinkClick: () => void; + }; } /** @@ -27,6 +33,7 @@ export function StatsBanner({ subtitle, stats = [], className, + subtitleWithLink, }: StatsBannerProps) { return (
{/* @@ -45,26 +52,38 @@ export function StatsBanner({ page background that is the same colour as the banner. */}