From 8dc4ae25dd153659b97a4de6f5a6803bb74060a8 Mon Sep 17 00:00:00 2001 From: sendi0011 Date: Mon, 19 Oct 2026 20:13:17 +0100 Subject: [PATCH 1/2] feat(routing): retire /projects and redirect it to the main app (#402) The main Boundless app already owns the public projects directory. Two directories for the same projects, with different filters and counts, is confusing, so this app keeps only the Featured projects row on the landing page and permanently redirects every retired `/projects` path to the main app. - Redirect `/projects` and `/projects/:path*` with a permanent 308 in `proxy.ts`. The destination is built from the shared `mainAppUrl` helper and never forwards the incoming query string, since the filter params differ between the two apps. `next.config.ts` redirects cannot drop the query in Next 16, so this lives in the proxy. - Delete the `/projects` route (`app/projects/`) and the components only it used (`projects-view`, `projects-grid`, `use-projects`, `category-tabs`, `discover-header`). `filter-rail.tsx` and `filter-sheet.tsx` stay: the builders directory still uses both. - Move `to-project-card.ts` next to its last user, `featured-projects.tsx`, and point the Featured row's "View all" at the main app via `mainAppUrl`. - Drop `/projects` from `lib/seo/routes.ts` so the sitemap stops listing it, and from the SEO check's HTML routes. Includes `lib/main-app.ts` from the #401 dependency so the branch builds on its own; it will de-dup when #401 merges. --- app/projects/opengraph-image.tsx | 12 - app/projects/page.tsx | 42 ---- app/projects/twitter-image.tsx | 1 - components/discover/category-tabs.tsx | 35 --- components/discover/discover-header.tsx | 22 -- components/discover/projects-grid.tsx | 81 ------- components/discover/projects-view.tsx | 214 ------------------ components/discover/use-projects.ts | 61 ----- components/landing/featured-projects.tsx | 5 +- .../{discover => landing}/to-project-card.ts | 0 lib/main-app.ts | 20 ++ lib/seo/routes.ts | 6 - proxy.ts | 20 ++ scripts/seo-check.mjs | 2 +- 14 files changed, 44 insertions(+), 477 deletions(-) delete mode 100644 app/projects/opengraph-image.tsx delete mode 100644 app/projects/page.tsx delete mode 100644 app/projects/twitter-image.tsx delete mode 100644 components/discover/category-tabs.tsx delete mode 100644 components/discover/discover-header.tsx delete mode 100644 components/discover/projects-grid.tsx delete mode 100644 components/discover/projects-view.tsx delete mode 100644 components/discover/use-projects.ts rename components/{discover => landing}/to-project-card.ts (100%) create mode 100644 lib/main-app.ts create mode 100644 proxy.ts 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/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/main-app.ts b/lib/main-app.ts new file mode 100644 index 00000000..4e78abea --- /dev/null +++ b/lib/main-app.ts @@ -0,0 +1,20 @@ +import { siteConfig } from '@/lib/seo/site'; + +/** + * Build an absolute URL on the main Boundless app. Paths are normalised to a + * leading slash so callers can pass either `'profile/jane'` or `'/profile/jane'`. + */ +export function mainAppUrl(path: string): string { + const normalized = path.startsWith('/') ? path : `/${path}`; + return `${siteConfig.parentUrl}${normalized}`; +} + +/** Public builder profile page in the main app. */ +export function profileUrl(username: string): string { + return mainAppUrl(`/profile/${encodeURIComponent(username)}`); +} + +/** Public project page in the main app. */ +export function projectUrl(slug: string): string { + return mainAppUrl(`/projects/${encodeURIComponent(slug)}`); +} 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']; From a8dff4825dafb3d3e4028cc18ace363ad1b1b1c6 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 22 Oct 2026 08:41:04 +0100 Subject: [PATCH 2/2] docs(discover): drop the projects directory from filter-rail comments --- components/discover/filter-rail.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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({