diff --git a/app/projects/opengraph-image.tsx b/app/projects/opengraph-image.tsx deleted file mode 100644 index 261c0d5d..00000000 --- a/app/projects/opengraph-image.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { renderOgImage, size, contentType } from '@/lib/seo/og-template'; - -export { size, contentType }; - -export const alt = 'Projects — Boundless Builders'; - -export default function Image() { - return renderOgImage({ - title: 'Projects', - subtitle: 'Explore the products being built across the Boundless ecosystem.', - }); -} diff --git a/app/projects/page.tsx b/app/projects/page.tsx deleted file mode 100644 index 5fd224c6..00000000 --- a/app/projects/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { ProjectsView } from '@/components/discover/projects-view'; -import { SiteFooter } from '@/components/layout/site-footer'; -import { SiteHeader } from '@/components/layout/site-header'; -import { JsonLd } from '@/components/seo/json-ld'; -import { buildPageMetadata } from '@/lib/seo/metadata'; -import { - breadcrumbSchema, - collectionPageSchema, - homeBreadcrumb, -} from '@/lib/seo/schema'; - -const PROJECTS_DESCRIPTION = - 'Explore the products and projects being built on Stellar through Boundless. See what teams across the ecosystem are shipping right now.'; - -export const metadata = buildPageMetadata({ - title: 'Projects', - description: PROJECTS_DESCRIPTION, - path: '/projects', -}); - -const projectsSchema = collectionPageSchema({ - name: 'Projects', - description: PROJECTS_DESCRIPTION, - path: '/projects', -}); -const projectsBreadcrumbs = breadcrumbSchema([ - homeBreadcrumb, - { name: 'Projects', path: '/projects' }, -]); - -export default function ProjectsPage() { - return ( - <> - - - - - - - - ); -} diff --git a/app/projects/twitter-image.tsx b/app/projects/twitter-image.tsx deleted file mode 100644 index a3205359..00000000 --- a/app/projects/twitter-image.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default, alt, size, contentType } from './opengraph-image'; diff --git a/components/discover/category-tabs.tsx b/components/discover/category-tabs.tsx deleted file mode 100644 index d4635174..00000000 --- a/components/discover/category-tabs.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; - -import { Button } from '@/components/ui/button'; - -export function CategoryTabs({ - categories, - active, - onSelect, -}: { - categories: readonly string[]; - active: string; - onSelect: (category: string) => void; -}) { - return ( -
- {categories.map(category => { - const isActive = category === active; - return ( - - ); - })} -
- ); -} diff --git a/components/discover/discover-header.tsx b/components/discover/discover-header.tsx deleted file mode 100644 index de3661aa..00000000 --- a/components/discover/discover-header.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** Page header for a discovery view: title with result count and subtext. */ -export function DiscoverHeader({ - heading, - subtext, - count, -}: { - heading: string; - subtext: string; - count?: number; -}) { - return ( -
-

- {heading} - {typeof count === 'number' ? ( - ({count}) - ) : null} -

-

{subtext}

-
- ); -} diff --git a/components/discover/filter-rail.tsx b/components/discover/filter-rail.tsx index 42edf93b..73130590 100644 --- a/components/discover/filter-rail.tsx +++ b/components/discover/filter-rail.tsx @@ -17,8 +17,8 @@ import { cn } from '@/lib/utils'; /** * Any selection state shaped as named groups of strings (skills, country, - * status, category, tags, ...). The generic shape is what lets the rail drive - * both the projects and builders directories from one component. + * status, ...). The parent decides which groups exist, so the rail itself + * stays generic. */ export type FilterValue = Record; @@ -198,9 +198,8 @@ function sectionRows( /** * Discovery filter controls, shared by the desktop sidebar and the mobile * sheet. Controlled: the parent owns `value` so it can show a Reset affordance - * and run the query. Sections are configured by the parent, so the projects - * (category/tags/status/origin) and builders (skills/country/status) pages - * render the same controls from one source. `idPrefix` keeps the two instances + * and run the query. Sections are configured by the parent (the builders + * directory passes skills/country/status). `idPrefix` keeps the two instances * from sharing input ids. */ export function FilterRail({ diff --git a/components/discover/projects-grid.tsx b/components/discover/projects-grid.tsx deleted file mode 100644 index ac6a2553..00000000 --- a/components/discover/projects-grid.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { OpportunityCard } from '@/components/cards/opportunity-card'; -import { OpportunityCardSkeleton } from '@/components/cards/opportunity-card-skeleton'; -import { Pagination } from '@/components/ui/pagination'; - -import { toProjectCard } from './to-project-card'; -import type { ProjectListResponse } from './use-projects'; - -const GRID_CLASS = 'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'; - -export function ProjectsGrid({ - data, - isPending, - isError, - isNarrowed, - page, - pageSize, - onPageChange, -}: { - data?: ProjectListResponse; - 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 ( -

- Projects could not be loaded right now. -

- ); - } - - if (data.data.length === 0) { - return ( -

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

- ); - } - - return ( -
-
- {data.data.map((project, index) => ( - - ))} -
- - {data.pagination.total > 0 && ( - - )} -
- ); -} diff --git a/components/discover/projects-view.tsx b/components/discover/projects-view.tsx deleted file mode 100644 index 3da58cdb..00000000 --- a/components/discover/projects-view.tsx +++ /dev/null @@ -1,214 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; - -import { - Activity01Icon, - CompassIcon, - HashtagIcon, - Tag02Icon, -} from '@/components/icons'; -import { Section } from '@/components/marketing/section'; - -import { CategoryTabs } from './category-tabs'; -import { DiscoverHeader } from './discover-header'; -import { DiscoverToolbar } from './discover-toolbar'; -import { - FilterRail, - hasActiveFilters, - type FilterSectionConfig, - type FilterValue, -} from './filter-rail'; -import { FilterSheet } from './filter-sheet'; -import { ProjectsGrid } from './projects-grid'; -import { - useProjectFilters, - useProjects, - type ProjectsQueryParams, -} from './use-projects'; - -const PAGE_SIZE = 12; -const ALL_CATEGORIES = 'All'; -const SEARCH_DEBOUNCE_MS = 300; - -const EMPTY_FILTERS: FilterValue = { - category: [], - tags: [], - publicStatus: [], - originType: [], -}; - -/** - * The `/projects` directory. Owns the search, category, filter, and page state - * and feeds it to `useProjects`, so the controls, the rail, and the grid all - * read from one source. The projects equivalent of the boundless `discover-view`, - * minus the pillar tabs, since this page is projects only. - */ -export function ProjectsView() { - const [searchInput, setSearchInput] = useState(''); - const [search, setSearch] = useState(''); - const [filters, setFilters] = useState(EMPTY_FILTERS); - const [page, setPage] = useState(1); - const [railOpen, setRailOpen] = useState(true); - const [sheetOpen, setSheetOpen] = useState(false); - - // 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(() => { - setSearch(searchInput); - setPage(1); - }, SEARCH_DEBOUNCE_MS); - return () => clearTimeout(timer); - }, [searchInput]); - - const applyFilters = (next: FilterValue) => { - setFilters(next); - setPage(1); - }; - - const params = useMemo( - () => ({ - page, - limit: PAGE_SIZE, - search: search || undefined, - // `GET /projects` takes a single value for these three, so a multi-select - // in the rail sends its first entry. Only `tags` is repeatable. - category: filters.category?.[0], - publicStatus: filters.publicStatus?.[0], - originType: filters.originType?.[0], - tags: filters.tags?.length ? filters.tags : undefined, - }), - [page, search, filters] - ); - - const { data, isError, isPending } = useProjects(params); - const { - data: facets, - isPending: facetsPending, - isError: facetsError, - } = useProjectFilters(); - - const categories = useMemo( - () => [ - ALL_CATEGORIES, - ...(facets?.categories.map(item => item.value) ?? []), - ], - [facets] - ); - - const filterSections = useMemo(() => { - if (!facets) return []; - return [ - { - group: 'publicStatus', - selection: 'single', - title: 'Status', - icon: Activity01Icon, - kind: 'enum', - items: facets.publicStatuses, - }, - { - group: 'originType', - selection: 'single', - title: 'Origin', - icon: CompassIcon, - kind: 'enum', - items: facets.originTypes, - }, - { - group: 'category', - selection: 'single', - title: 'Category', - icon: HashtagIcon, - kind: 'facet', - items: facets.categories, - }, - { - group: 'tags', - selection: 'multi', - title: 'Tags', - icon: Tag02Icon, - kind: 'facet', - defaultOpen: false, - items: facets.tags, - }, - ]; - }, [facets]); - - const reset = () => applyFilters(EMPTY_FILTERS); - - const selectCategory = (category: string) => - applyFilters({ - ...filters, - category: category === ALL_CATEGORIES ? [] : [category], - }); - - return ( -
- - - - - setRailOpen(open => !open)} - onOpenMobileFilters={() => setSheetOpen(true)} - filtersActive={hasActiveFilters(filters)} - onReset={reset} - query={searchInput} - onQueryChange={setSearchInput} - placeholder='Search projects, categories, or tags' - /> - -
- {railOpen ? ( - - ) : null} - -
- 0} - page={page} - pageSize={PAGE_SIZE} - onPageChange={setPage} - /> -
-
- - - - -
- ); -} diff --git a/components/discover/use-projects.ts b/components/discover/use-projects.ts deleted file mode 100644 index 30c398e6..00000000 --- a/components/discover/use-projects.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { apiFetch } from '@/lib/api/client'; -import type { Schemas } from '@/lib/api/types'; - -/** One page of `/projects`. */ -export type ProjectListResponse = Schemas['PublicProjectListDto']; - -/** Facets for the `/projects` filter rail. */ -export type ProjectFilters = Schemas['ProjectFiltersDto']; - -/** Query params `GET /projects` accepts from the directory page. */ -export interface ProjectsQueryParams { - page?: number; - limit?: number; - search?: string; - category?: string; - /** Repeatable filter, sent as a comma-separated `tags` value. */ - tags?: string[]; - publicStatus?: string; - originType?: string; -} - -export const projectsKeys = { - all: ['projects'] as const, - list: (params: ProjectsQueryParams) => - ['projects', 'list', params] as const, - filters: ['projects', 'filters'] as const, -}; - -function toQueryString(params: ProjectsQueryParams): 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.category) search.set('category', params.category); - if (params.tags?.length) search.set('tags', params.tags.join(',')); - if (params.publicStatus) search.set('publicStatus', params.publicStatus); - if (params.originType) search.set('originType', params.originType); - - const query = search.toString(); - return query ? `?${query}` : ''; -} - -/** Paginated `/projects` directory list, filtered by the params above. */ -export function useProjects(params: ProjectsQueryParams = {}) { - return useQuery({ - queryKey: projectsKeys.list(params), - queryFn: () => - apiFetch(`/projects${toQueryString(params)}`), - }); -} - -/** Filter facets (categories, tags, statuses, origin types) for `/projects`. */ -export function useProjectFilters() { - return useQuery({ - queryKey: projectsKeys.filters, - queryFn: () => apiFetch('/projects/filters'), - }); -} diff --git a/components/landing/featured-projects.tsx b/components/landing/featured-projects.tsx index 51cbc141..a2faa73b 100644 --- a/components/landing/featured-projects.tsx +++ b/components/landing/featured-projects.tsx @@ -10,8 +10,9 @@ 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 { toProjectCard } from '@/components/discover/to-project-card'; +import { toProjectCard } from './to-project-card'; const FEATURED_PROJECTS_SKELETON_COUNT = 3; @@ -84,7 +85,7 @@ export function FeaturedProjects() { size='large' asChild > - + Browse all projects diff --git a/components/discover/to-project-card.ts b/components/landing/to-project-card.ts similarity index 100% rename from components/discover/to-project-card.ts rename to components/landing/to-project-card.ts diff --git a/lib/seo/routes.ts b/lib/seo/routes.ts index a484dd41..0d6d3dc4 100644 --- a/lib/seo/routes.ts +++ b/lib/seo/routes.ts @@ -31,10 +31,4 @@ export const routes: RouteEntry[] = [ changeFrequency: 'daily', priority: 0.9, }, - { - slug: '/projects', - label: 'Projects', - changeFrequency: 'daily', - priority: 0.9, - }, ]; diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 00000000..c46d82bb --- /dev/null +++ b/proxy.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +import { mainAppUrl } from '@/lib/main-app'; + +/** + * This app no longer hosts a projects directory; the main Boundless app owns + * it. Every `/projects` path redirects there permanently. + * + * This lives in the proxy rather than `next.config.ts` because config redirects + * always forward the incoming query string, and the filter parameters differ + * between the two apps. Here the destination is built by hand, so nothing leaks + * across. + */ +export function proxy() { + return NextResponse.redirect(mainAppUrl('/projects'), 308); +} + +export const config = { + matcher: ['/projects', '/projects/:path*'], +}; diff --git a/scripts/seo-check.mjs b/scripts/seo-check.mjs index 0b43a4b7..289da3af 100644 --- a/scripts/seo-check.mjs +++ b/scripts/seo-check.mjs @@ -47,7 +47,7 @@ const START_TIMEOUT_MS = 60_000; const FETCH_TIMEOUT_MS = 15_000; /** HTML routes to check for all four SEO signals. */ -const HTML_ROUTES = ['/', '/about', '/builders', '/projects']; +const HTML_ROUTES = ['/', '/about', '/builders']; /** Plain-text / XML routes that must return HTTP 200. */ const STATUS_ROUTES = ['/robots.txt', '/sitemap.xml'];