From 8c627e27e9d379fdbf7c1e19b043d5afceec2a35 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:22:09 +0530 Subject: [PATCH 1/4] feat: show nearbuilders.org builders and projects on community pages Adds a Builders and projects section to the public community page. The API builds the community path from the node and its parents, reads nearbuilders.org /v1/builders and /v1/projects with ?cityNode=, keeps only items tagged with that path, and reports nearbuilders.org as unavailable instead of failing. The base URL is the nearbuildersUrl API variable (default https://nearbuilders.org). Closes #451 --- api/src/discovery-contract.ts | 27 +++ api/src/index.ts | 13 ++ api/src/services/nearbuilders.ts | 184 +++++++++++++++ .../integration/community-builders.test.ts | 185 +++++++++++++++ bos.app.ts | 1 + ui/src/i18n/messages/public.ts | 50 +++++ ui/src/routes/_public/n/$slug.tsx | 3 + .../_public/n/-nearbuilders-section.tsx | 212 ++++++++++++++++++ ui/src/routes/_public/n/-node-page.test.tsx | 88 +++++++- 9 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 api/src/services/nearbuilders.ts create mode 100644 api/tests/integration/community-builders.test.ts create mode 100644 ui/src/routes/_public/n/-nearbuilders-section.tsx diff --git a/api/src/discovery-contract.ts b/api/src/discovery-contract.ts index fda67d404..29004ca41 100644 --- a/api/src/discovery-contract.ts +++ b/api/src/discovery-contract.ts @@ -25,6 +25,30 @@ export const profileSchema = z ) .refine((p) => p.latitude === null || p.location.length > 0, "Confirm the location label"); export type DiscoveryProfile = z.infer; +export const communityBuildersSchema = z.object({ + path: z.string(), + baseUrl: z.string(), + status: z.enum(["ok", "unavailable"]), + builders: z.object({ + items: z.array( + z.object({ nearAccount: z.string(), name: z.string().nullable(), url: z.string() }), + ), + total: z.number().int().nonnegative(), + url: z.string(), + }), + projects: z.object({ + items: z.array( + z.object({ + title: z.string(), + description: z.string().nullable(), + logoUrl: z.string().nullable(), + url: z.string(), + }), + ), + total: z.number().int().nonnegative(), + url: z.string(), + }), +}); export const activitySchema = z.object({ id: z.uuid(), ownerNodeId: z.uuid(), @@ -268,6 +292,9 @@ export const discoveryContract = { ) .output(z.array(discoveryNodeSchema)), getDiscoveryNode: oc.input(z.object({ nodeId: z.uuid() })).output(discoveryNodeSchema.nullable()), + getCommunityBuilders: oc + .input(z.object({ nodeId: z.uuid() })) + .output(communityBuildersSchema.nullable()), getDiscoveryProfile: oc .input(z.object({ nodeId: z.uuid() })) .output(profileSchema.nullable()) diff --git a/api/src/index.ts b/api/src/index.ts index 3c93fa269..402883be0 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -23,6 +23,7 @@ import { verifyDaoMembership } from "./services/dao"; import type { DiscoveryService } from "./services/discovery"; import { DiscoveryLive, DiscoveryTag } from "./services/discovery"; import { GeocodeLive } from "./services/discovery-geocode"; +import { communityPath, createNearbuilders } from "./services/nearbuilders"; import type { NodeEffect, NodeRecord, NodesService } from "./services/nodes"; import { NodesLive, NodesTag } from "./services/nodes"; import { @@ -58,6 +59,7 @@ class ApiServices extends Context.Service< validators: ValidatorsService; discovery: DiscoveryService; storage: StorageService; + nearbuilders: ReturnType; } >()("api/ApiServices") {} @@ -312,6 +314,10 @@ export default createPlugin.withPlugins()({ .string() .default("") .describe("Repository URL included in the Nominatim User-Agent"), + nearbuildersUrl: z + .string() + .default("https://nearbuilders.org") + .describe("NEAR Builders base URL for community builders and projects"), }), secrets: z.object({ @@ -357,6 +363,7 @@ export default createPlugin.withPlugins()({ validators: Context.get(services, ValidatorsTag), discovery: Context.get(services, DiscoveryTag), storage: Context.get(services, StorageTag), + nearbuilders: createNearbuilders(config.variables.nearbuildersUrl), }), ); }), @@ -621,6 +628,12 @@ export default createPlugin.withPlugins()({ const services = yield* ApiServices; return yield* services.discovery.get(input.nodeId); }), + getCommunityBuilders: builder.getCommunityBuilders.effect(function* ({ input }) { + const services = yield* ApiServices; + const path = yield* communityPath(input.nodeId, (id) => services.nodes.getById(id)); + if (!path) return null; + return yield* Effect.promise(() => services.nearbuilders.forPath(path)); + }), getDiscoveryProfile: builder.getDiscoveryProfile.effect(function* ({ input, context }) { const services = yield* ApiServices; return yield* services.discovery.profile(input.nodeId, context); diff --git a/api/src/services/nearbuilders.ts b/api/src/services/nearbuilders.ts new file mode 100644 index 000000000..a732d6e69 --- /dev/null +++ b/api/src/services/nearbuilders.ts @@ -0,0 +1,184 @@ +import { Effect } from "effect"; +import { z } from "zod"; + +export const NEARBUILDERS_PREVIEW_LIMIT = 6; +const REQUEST_TIMEOUT_MS = 5000; +const CACHE_TTL_MS = 2 * 60 * 1000; +const MAX_DEPTH = 10; + +const builderSchema = z.object({ + nearAccount: z.string().min(1), + name: z.string().nullable().optional(), + cityNode: z.string().nullable().optional(), + withdrawnAt: z.string().nullable().optional(), +}); +const projectSchema = z.object({ + kind: z.string().min(1), + slug: z.string().min(1), + title: z.string().min(1), + description: z.string().nullable().optional(), + logoUrl: z.string().nullable().optional(), + visibility: z.string().optional(), + cityNode: z.string().nullable().optional(), +}); +const pageSchema = z.object({ + data: z.array(z.unknown()), + meta: z.object({ total: z.number().int().nonnegative() }).optional(), +}); + +export interface CommunityBuilder { + nearAccount: string; + name: string | null; + url: string; +} + +export interface CommunityProject { + title: string; + description: string | null; + logoUrl: string | null; + url: string; +} + +export interface CommunityBuilders { + path: string; + baseUrl: string; + status: "ok" | "unavailable"; + builders: { items: CommunityBuilder[]; total: number; url: string }; + projects: { items: CommunityProject[]; total: number; url: string }; +} + +export function normalizeBaseUrl(value: string) { + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("protocol"); + return url.origin; + } catch { + return "https://nearbuilders.org"; + } +} + +export function communityPath( + nodeId: string, + getNode: (id: string) => Effect.Effect<{ slug: string; parentId: string | null } | null, E>, +) { + return Effect.gen(function* () { + const slugs: string[] = []; + const seen = new Set(); + let current: string | null = nodeId; + while (current && !seen.has(current) && slugs.length < MAX_DEPTH) { + seen.add(current); + const node: { slug: string; parentId: string | null } | null = yield* getNode(current); + if (!node) break; + slugs.push(node.slug.toLowerCase()); + current = node.parentId; + } + return slugs.length === 0 ? null : slugs.reverse().join("/"); + }); +} + +function httpUrl(value: string | null | undefined) { + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.href : null; + } catch { + return null; + } +} + +function matchedPage( + raw: unknown, + schema: z.ZodType, + matches: (item: T) => boolean, +): { items: T[]; total: number } { + const page = pageSchema.parse(raw); + const items = page.data.flatMap((entry) => { + const parsed = schema.safeParse(entry); + return parsed.success && matches(parsed.data) ? [parsed.data] : []; + }); + const filteredByServer = items.length === page.data.length; + const total = filteredByServer ? Math.max(page.meta?.total ?? 0, items.length) : items.length; + return { items, total }; +} + +export function createNearbuilders(baseUrlInput: string) { + const baseUrl = normalizeBaseUrl(baseUrlInput); + const cache = new Map(); + + async function request(path: string, params: Record) { + const url = new URL(`/api${path}`, baseUrl); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`nearbuilders.org responded with HTTP ${response.status}`); + return response.json(); + } + + function empty(path: string, status: CommunityBuilders["status"]): CommunityBuilders { + return { + path, + baseUrl, + status, + builders: { items: [], total: 0, url: `${baseUrl}/builders` }, + projects: { items: [], total: 0, url: `${baseUrl}/projects` }, + }; + } + + async function load(path: string): Promise { + const [builderPage, projectPage] = await Promise.all([ + request("/v1/builders", { cityNode: path }), + request("/v1/projects", { cityNode: path, visibility: "public", limit: "24" }), + ]); + const builders = matchedPage( + builderPage, + builderSchema, + (builder) => builder.cityNode === path && !builder.withdrawnAt, + ); + const projects = matchedPage( + projectPage, + projectSchema, + (project) => + project.cityNode === path && (!project.visibility || project.visibility === "public"), + ); + const result = empty(path, "ok"); + return { + ...result, + builders: { + ...result.builders, + total: builders.total, + items: builders.items.slice(0, NEARBUILDERS_PREVIEW_LIMIT).map((builder) => ({ + nearAccount: builder.nearAccount, + name: builder.name?.trim() || null, + url: `${baseUrl}/builders/${encodeURIComponent(builder.nearAccount)}`, + })), + }, + projects: { + ...result.projects, + total: projects.total, + items: projects.items.slice(0, NEARBUILDERS_PREVIEW_LIMIT).map((project) => ({ + title: project.title, + description: project.description?.trim() || null, + logoUrl: httpUrl(project.logoUrl), + url: `${baseUrl}/projects/${encodeURIComponent(project.kind)}/${encodeURIComponent(project.slug)}`, + })), + }, + }; + } + + return { + baseUrl, + forPath: async (path: string): Promise => { + const cached = cache.get(path); + if (cached && cached.expiresAt > Date.now()) return cached.value; + try { + const value = await load(path); + cache.set(path, { expiresAt: Date.now() + CACHE_TTL_MS, value }); + return value; + } catch { + return cached?.value ?? empty(path, "unavailable"); + } + }, + }; +} diff --git a/api/tests/integration/community-builders.test.ts b/api/tests/integration/community-builders.test.ts new file mode 100644 index 000000000..76737fc82 --- /dev/null +++ b/api/tests/integration/community-builders.test.ts @@ -0,0 +1,185 @@ +import { Effect } from "effect"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { daoContext, getPluginClient, teardown } from "../setup"; + +vi.mock("@/services/dao", () => ({ + verifyDaoMembership: vi.fn(() => + Effect.succeed({ isSputnikContract: true, isMember: true, policy: { roles: [] } }), + ), + parsePolicyGroupMembers: vi.fn(() => []), + isExplicitDaoMember: vi.fn(() => true), +})); + +const ORG = "community-builders-org"; +const NB = "https://nearbuilders.org"; + +function builder(nearAccount: string, cityNode: string | null, name: string | null = null) { + return { + id: `bld_${nearAccount}`, + nearAccount, + userId: null, + name, + bio: null, + skills: [], + location: null, + links: null, + cityNode, + withdrawnAt: null, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + }; +} + +function project(slug: string, cityNode: string | null, visibility = "public") { + return { + id: `proj_${slug}`, + ownerId: "owner.near", + organizationId: null, + kind: "project", + slug, + title: `Project ${slug}`, + description: `About ${slug}`, + content: null, + status: "active", + visibility, + repository: null, + domain: null, + logoUrl: "https://example.com/logo.png", + cityNode, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + }; +} + +function stubNearbuilders( + respond: (url: URL) => Response | Promise, + requests: URL[] = [], +) { + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== NB) return realFetch(input, init); + requests.push(url); + return respond(url); + }); + return requests; +} + +async function spawnNested() { + const owner = await getPluginClient(daoContext("builders-owner", ORG, "builders-owner.near")); + const suffix = crypto.randomUUID().slice(0, 8); + const state = await owner.spawnNode({ + kind: "state", + slug: `testland-${suffix}`, + name: "Testland", + parentId: null, + }); + const city = await owner.spawnNode({ + kind: "city", + slug: `sample-city-${suffix}`, + name: "Sample City", + parentId: state.id, + }); + return { state, city, path: `${state.slug}/${city.slug}` }; +} + +describe("community builders from nearbuilders.org", () => { + beforeAll(async () => { + await getPluginClient(); + }, 30_000); + afterEach(() => vi.unstubAllGlobals()); + afterAll(teardown); + + it("queries a nested community by its path and keeps only items tagged with it", async () => { + const { city, path } = await spawnNested(); + const requests = stubNearbuilders((url) => + url.pathname === "/api/v1/builders" + ? Response.json({ + data: [ + builder("alice.testnet", path, "Alice Example"), + builder("bob.testnet", path), + builder("other.testnet", "elsewhere"), + ], + meta: { total: 3, hasMore: false, nextCursor: null }, + }) + : Response.json({ + data: [ + project("demo-app", path), + project("hidden-app", path, "private"), + project("far-app", null), + ], + meta: { total: 3, hasMore: false, nextCursor: null }, + }), + ); + + const client = await getPluginClient(); + const result = await client.getCommunityBuilders({ nodeId: city.id }); + + expect(requests.map((url) => url.searchParams.get("cityNode"))).toEqual([path, path]); + expect(result).toMatchObject({ + path, + status: "ok", + builders: { + total: 2, + url: `${NB}/builders`, + items: [ + { + nearAccount: "alice.testnet", + name: "Alice Example", + url: `${NB}/builders/alice.testnet`, + }, + { nearAccount: "bob.testnet", name: null, url: `${NB}/builders/bob.testnet` }, + ], + }, + projects: { + total: 1, + items: [ + { + title: "Project demo-app", + description: "About demo-app", + logoUrl: "https://example.com/logo.png", + url: `${NB}/projects/project/demo-app`, + }, + ], + }, + }); + }); + + it("uses the slug alone for a top-level community and shows nothing when the filter is ignored", async () => { + const { state } = await spawnNested(); + stubNearbuilders((url) => + Response.json({ + data: + url.pathname === "/api/v1/builders" + ? [builder("someone.testnet", null)] + : [project("untagged", null)], + meta: { total: 40, hasMore: true, nextCursor: "next" }, + }), + ); + + const client = await getPluginClient(); + const result = await client.getCommunityBuilders({ nodeId: state.id }); + + expect(result).toMatchObject({ + path: state.slug, + status: "ok", + builders: { items: [], total: 0 }, + projects: { items: [], total: 0 }, + }); + }); + + it("reports nearbuilders.org as unavailable instead of failing", async () => { + const { city } = await spawnNested(); + stubNearbuilders(() => new Response("Bad gateway", { status: 502 })); + + const client = await getPluginClient(); + const result = await client.getCommunityBuilders({ nodeId: city.id }); + + expect(result).toMatchObject({ status: "unavailable", builders: { items: [] } }); + }); + + it("returns null for an unknown community", async () => { + const client = await getPluginClient(); + expect(await client.getCommunityBuilders({ nodeId: crypto.randomUUID() })).toBeNull(); + }); +}); diff --git a/bos.app.ts b/bos.app.ts index 24e5c9ec9..601040605 100644 --- a/bos.app.ts +++ b/bos.app.ts @@ -24,6 +24,7 @@ export default App({ platformAccount: "v1.citynode.near", domain: "citynode.app", gatewayDomains: "citynode.app,testnet.citynode.app", + nearbuildersUrl: "https://nearbuilders.org", }, secrets: ["API_DATABASE_URL", "LUMA_CALENDAR_API_KEYS"], }, diff --git a/ui/src/i18n/messages/public.ts b/ui/src/i18n/messages/public.ts index 427efa823..0ac15658e 100644 --- a/ui/src/i18n/messages/public.ts +++ b/ui/src/i18n/messages/public.ts @@ -942,6 +942,56 @@ export const publicMessages = createMessageCatalogs(["en", "es", "fr", "zh"], { "Restitue les NEAR à {account}.", "将 NEAR 转回 {account}。", ], + "nearbuilders.builders": ["Builders", "Builders", "Builders", "开发者"], + "nearbuilders.descriptionNamed": [ + "People and projects on NEAR Builders tagged with {name}.", + "Personas y proyectos en NEAR Builders etiquetados con {name}.", + "Personnes et projets sur NEAR Builders associés à {name}.", + "NEAR Builders 上标记为 {name} 的开发者和项目。", + ], + "nearbuilders.empty": [ + "No builders or projects are tagged with this community yet.", + "Aún no hay builders ni proyectos etiquetados con esta comunidad.", + "Aucun builder ni projet n’est encore associé à cette communauté.", + "目前还没有开发者或项目标记为此社区。", + ], + "nearbuilders.join": [ + "Join NEAR Builders", + "Únete a NEAR Builders", + "Rejoindre NEAR Builders", + "加入 NEAR Builders", + ], + "nearbuilders.noBuilders": [ + "No builders tagged yet.", + "Aún no hay builders etiquetados.", + "Aucun builder associé pour le moment.", + "暂无标记的开发者。", + ], + "nearbuilders.noProjects": [ + "No projects tagged yet.", + "Aún no hay proyectos etiquetados.", + "Aucun projet associé pour le moment.", + "暂无标记的项目。", + ], + "nearbuilders.projects": ["Projects", "Proyectos", "Projets", "项目"], + "nearbuilders.seeAll": [ + "See all on NEAR Builders", + "Ver todo en NEAR Builders", + "Tout voir sur NEAR Builders", + "在 NEAR Builders 查看全部", + ], + "nearbuilders.title": [ + "Builders and projects", + "Builders y proyectos", + "Builders et projets", + "开发者和项目", + ], + "nearbuilders.unavailable": [ + "Couldn’t load builders and projects from NEAR Builders right now.", + "No se pudieron cargar los builders y proyectos de NEAR Builders en este momento.", + "Impossible de charger les builders et projets de NEAR Builders pour le moment.", + "暂时无法从 NEAR Builders 加载开发者和项目。", + ], "payout.stepUnstake": [ "Step 1 of 2 · Unstake from the pool (a treasury vote)", "Paso 1 de 2 · Retirar del pool (votación de la tesorería)", diff --git a/ui/src/routes/_public/n/$slug.tsx b/ui/src/routes/_public/n/$slug.tsx index 25445809b..3a32b081b 100644 --- a/ui/src/routes/_public/n/$slug.tsx +++ b/ui/src/routes/_public/n/$slug.tsx @@ -24,6 +24,7 @@ import { nodeBySlugQueryOptions, stakingValidatorsQueryOptions, } from "@/lib/queries/nodes"; +import { NearbuildersSection } from "./-nearbuilders-section"; import { ProposeHomepageCta } from "./-propose-homepage-cta"; function discoveryProfileQueryOptions(apiClient: ApiClient, nodeId: string) { @@ -300,6 +301,8 @@ function NodePage() { sourceNodeId={staking?.sourceNodeId ?? node.id} apiClient={apiClient} /> + + ); } diff --git a/ui/src/routes/_public/n/-nearbuilders-section.tsx b/ui/src/routes/_public/n/-nearbuilders-section.tsx new file mode 100644 index 000000000..31cf02306 --- /dev/null +++ b/ui/src/routes/_public/n/-nearbuilders-section.tsx @@ -0,0 +1,212 @@ +import { + ArrowUpRightIcon, + CaretRightIcon, + CloudSlashIcon, + UsersThreeIcon, +} from "@phosphor-icons/react"; +import { queryOptions, useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { type ApiClient, useApiClient, useAuthClient } from "@/app"; +import { Avatar, AvatarFallback, AvatarImage, Button, SectionHeader } from "@/components"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAppTranslation } from "@/i18n/runtime"; +import { getNearInitials, resolveNearImageUrl } from "@/lib/near-profile"; + +type CommunityBuilders = NonNullable>>; +type Builder = CommunityBuilders["builders"]["items"][number]; +type Project = CommunityBuilders["projects"]["items"][number]; + +const cardClassName = + "group flex h-full min-h-16 items-center gap-4 rounded-2xl border border-border bg-card p-4 outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"; + +export function communityBuildersQueryOptions(apiClient: ApiClient, nodeId: string) { + return queryOptions({ + queryKey: ["community-builders", nodeId], + queryFn: () => apiClient.getCommunityBuilders({ nodeId }), + enabled: !!nodeId, + retry: false, + staleTime: 60_000, + }); +} + +export function NearbuildersSection({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const translate = useAppTranslation(); + const apiClient = useApiClient(); + const { data, isLoading, isError } = useQuery(communityBuildersQueryOptions(apiClient, nodeId)); + + const unavailable = isError || data?.status === "unavailable"; + const builders = data?.builders.items ?? []; + const projects = data?.projects.items ?? []; + const joinUrl = `${data?.baseUrl ?? "https://nearbuilders.org"}/join`; + + return ( +
+ + {isLoading ? ( +
+ + + +
+ ) : unavailable || !data ? ( + }> +

+ {translate("nearbuilders.unavailable")} +

+
+ ) : builders.length === 0 && projects.length === 0 ? ( + }> +
+

+ {translate("nearbuilders.empty")} +

+ +
+
+ ) : ( +
+ builders.length ? data.builders.url : null} + testId="node-page.nearbuilders-builders" + > + {builders.map((builder) => ( +
  • + +
  • + ))} +
    + projects.length ? data.projects.url : null} + testId="node-page.nearbuilders-projects" + > + {projects.map((project) => ( +
  • + +
  • + ))} +
    +
    + )} +
    + ); +} + +function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( +
    + {icon} + {children} +
    + ); +} + +function Group({ + title, + empty, + seeAllUrl, + testId, + children, +}: { + title: string; + empty: string; + seeAllUrl: string | null; + testId: string; + children: ReactNode[]; +}) { + const translate = useAppTranslation(); + return ( +
    + + {children.length > 0 ? ( +
      {children}
    + ) : ( +

    {empty}

    + )} +
    + ); +} + +function BuilderCard({ builder }: { builder: Builder }) { + const authClient = useAuthClient(); + const { data: profile } = useQuery({ + queryKey: ["near-profile", builder.nearAccount], + queryFn: async () => { + const { data } = await authClient.near.getProfile(builder.nearAccount); + return data ?? null; + }, + staleTime: 5 * 60 * 1000, + retry: false, + }); + const name = builder.name || profile?.name || builder.nearAccount; + const avatarUrl = resolveNearImageUrl(profile?.image); + + return ( + + + {avatarUrl ? : null} + {getNearInitials(name)} + +
    +
    {name}
    +
    {builder.nearAccount}
    +
    + +
    + ); +} + +function ProjectCard({ project }: { project: Project }) { + return ( + + + {project.logoUrl ? ( + + ) : null} + {getNearInitials(project.title)} + +
    +
    {project.title}
    + {project.description && ( +

    {project.description}

    + )} +
    + +
    + ); +} diff --git a/ui/src/routes/_public/n/-node-page.test.tsx b/ui/src/routes/_public/n/-node-page.test.tsx index 4a9ce2c0d..e0c27d7c4 100644 --- a/ui/src/routes/_public/n/-node-page.test.tsx +++ b/ui/src/routes/_public/n/-node-page.test.tsx @@ -60,7 +60,22 @@ afterEach(() => { vi.unstubAllGlobals(); }); -function mockApi(nodes = [parent, child], ownPoolNodeId?: string) { +function communityBuilders(path: string, overrides: Record = {}) { + return { + path, + baseUrl: "https://nearbuilders.example", + status: "ok", + builders: { items: [], total: 0, url: "https://nearbuilders.example/builders" }, + projects: { items: [], total: 0, url: "https://nearbuilders.example/projects" }, + ...overrides, + }; +} + +function mockApi( + nodes = [parent, child], + ownPoolNodeId?: string, + nearbuilders: (nodeId: string) => unknown = () => null, +) { vi.stubGlobal("scrollTo", vi.fn()); vi.spyOn(Near.prototype, "view").mockImplementation((_contractId, method) => { const values: Record = { @@ -104,6 +119,8 @@ function mockApi(nodes = [parent, child], ownPoolNodeId?: string) { } case "getDiscoveryNode": return Response.json({ json: null }); + case "getCommunityBuilders": + return Response.json({ json: nearbuilders(args.nodeId) }); case "getNode": return Response.json({ json: nodes.find((entry) => entry.id === args.nodeId) ?? null }); case "getSubtree": @@ -333,3 +350,72 @@ it("offers a way back to Explore when the community does not exist", async () => expect(await screen.findByRole("heading", { name: "Community not found" })).toBeTruthy(); expect(screen.getByTestId("node-page.back-to-explore").getAttribute("href")).toBe("/explore"); }); + +it("lists builders and projects tagged with a nested community, linking to nearbuilders.org", async () => { + mockApi([parent, child], child.id, (nodeId) => + nodeId === child.id + ? communityBuilders("illinois/chicago", { + builders: { + items: [ + { + nearAccount: "ada.testnet", + name: "Ada Example", + url: "https://nearbuilders.example/builders/ada.testnet", + }, + ], + total: 9, + url: "https://nearbuilders.example/builders", + }, + projects: { + items: [ + { + title: "Lakeside Tools", + description: "Tools for the lake.", + logoUrl: null, + url: "https://nearbuilders.example/projects/project/lakeside-tools", + }, + ], + total: 1, + url: "https://nearbuilders.example/projects", + }, + }) + : null, + ); + await showNode("/n/chicago"); + expect(await screen.findByRole("heading", { name: "Builders and projects" })).toBeTruthy(); + const builderLink = await screen.findByRole("link", { name: /Ada Example ada\.testnet/ }); + expect(builderLink.getAttribute("href")).toBe( + "https://nearbuilders.example/builders/ada.testnet", + ); + const projectLink = screen.getByRole("link", { name: /Lakeside Tools Tools for the lake\./ }); + expect(projectLink.getAttribute("href")).toBe( + "https://nearbuilders.example/projects/project/lakeside-tools", + ); + const seeAll = screen.getAllByRole("link", { name: "See all on NEAR Builders" }); + expect(seeAll.map((link) => link.getAttribute("href"))).toEqual([ + "https://nearbuilders.example/builders", + ]); +}); + +it("shows an empty state with a link to join NEAR Builders", async () => { + mockApi([parent, child], undefined, () => communityBuilders("illinois")); + await showNode("/n/illinois"); + expect( + await screen.findByText("No builders or projects are tagged with this community yet."), + ).toBeTruthy(); + expect(screen.getByTestId("node-page.nearbuilders-join").getAttribute("href")).toBe( + "https://nearbuilders.example/join", + ); +}); + +it("keeps the community page working when nearbuilders.org is unavailable", async () => { + mockApi([parent, child], undefined, () => + communityBuilders("illinois", { status: "unavailable" }), + ); + await showNode("/n/illinois"); + expect( + await screen.findByText("Couldn’t load builders and projects from NEAR Builders right now."), + ).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Illinois", level: 1 })).toBeTruthy(); + expect(screen.queryByTestId("node-page.nearbuilders-join")).toBeNull(); +}); From cd0cd3cd2e5cbd3835a400a563f9909612b194c0 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:31:45 +0530 Subject: [PATCH 2/4] fix: tighten nearbuilders community lookups Return no path when a community's ancestor chain doesn't reach a root, keep only https project logos, count totals from raw tags, share one in-flight request per path, prune stale cache entries after an hour, and read the join link from the API base URL. --- api/src/services/nearbuilders.ts | 66 +++++++---- api/tests/unit/nearbuilders.test.ts | 111 ++++++++++++++++++ .../_public/n/-nearbuilders-section.tsx | 3 +- 3 files changed, 156 insertions(+), 24 deletions(-) create mode 100644 api/tests/unit/nearbuilders.test.ts diff --git a/api/src/services/nearbuilders.ts b/api/src/services/nearbuilders.ts index a732d6e69..c401738de 100644 --- a/api/src/services/nearbuilders.ts +++ b/api/src/services/nearbuilders.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const NEARBUILDERS_PREVIEW_LIMIT = 6; const REQUEST_TIMEOUT_MS = 5000; const CACHE_TTL_MS = 2 * 60 * 1000; +const STALE_TTL_MS = 60 * 60 * 1000; const MAX_DEPTH = 10; const builderSchema = z.object({ @@ -21,6 +22,7 @@ const projectSchema = z.object({ visibility: z.string().optional(), cityNode: z.string().nullable().optional(), }); +const taggedSchema = z.object({ cityNode: z.string().nullable().optional() }); const pageSchema = z.object({ data: z.array(z.unknown()), meta: z.object({ total: z.number().int().nonnegative() }).optional(), @@ -65,45 +67,52 @@ export function communityPath( const slugs: string[] = []; const seen = new Set(); let current: string | null = nodeId; - while (current && !seen.has(current) && slugs.length < MAX_DEPTH) { + while (current) { + if (seen.has(current) || slugs.length >= MAX_DEPTH) return null; seen.add(current); const node: { slug: string; parentId: string | null } | null = yield* getNode(current); - if (!node) break; + if (!node) return null; slugs.push(node.slug.toLowerCase()); current = node.parentId; } - return slugs.length === 0 ? null : slugs.reverse().join("/"); + return slugs.reverse().join("/"); }); } -function httpUrl(value: string | null | undefined) { +function httpsUrl(value: string | null | undefined) { if (!value) return null; try { const url = new URL(value); - return url.protocol === "https:" || url.protocol === "http:" ? url.href : null; + return url.protocol === "https:" ? url.href : null; } catch { return null; } } -function matchedPage( +function matchedPage( raw: unknown, schema: z.ZodType, + path: string, matches: (item: T) => boolean, ): { items: T[]; total: number } { const page = pageSchema.parse(raw); const items = page.data.flatMap((entry) => { const parsed = schema.safeParse(entry); - return parsed.success && matches(parsed.data) ? [parsed.data] : []; + return parsed.success && parsed.data.cityNode === path && matches(parsed.data) + ? [parsed.data] + : []; }); - const filteredByServer = items.length === page.data.length; + const filteredByServer = page.data.every( + (entry) => taggedSchema.safeParse(entry).data?.cityNode === path, + ); const total = filteredByServer ? Math.max(page.meta?.total ?? 0, items.length) : items.length; return { items, total }; } export function createNearbuilders(baseUrlInput: string) { const baseUrl = normalizeBaseUrl(baseUrlInput); - const cache = new Map(); + const cache = new Map(); + const pending = new Map>(); async function request(path: string, params: Record) { const url = new URL(`/api${path}`, baseUrl); @@ -134,13 +143,14 @@ export function createNearbuilders(baseUrlInput: string) { const builders = matchedPage( builderPage, builderSchema, - (builder) => builder.cityNode === path && !builder.withdrawnAt, + path, + (builder) => !builder.withdrawnAt, ); const projects = matchedPage( projectPage, projectSchema, - (project) => - project.cityNode === path && (!project.visibility || project.visibility === "public"), + path, + (project) => !project.visibility || project.visibility === "public", ); const result = empty(path, "ok"); return { @@ -160,7 +170,7 @@ export function createNearbuilders(baseUrlInput: string) { items: projects.items.slice(0, NEARBUILDERS_PREVIEW_LIMIT).map((project) => ({ title: project.title, description: project.description?.trim() || null, - logoUrl: httpUrl(project.logoUrl), + logoUrl: httpsUrl(project.logoUrl), url: `${baseUrl}/projects/${encodeURIComponent(project.kind)}/${encodeURIComponent(project.slug)}`, })), }, @@ -169,16 +179,28 @@ export function createNearbuilders(baseUrlInput: string) { return { baseUrl, - forPath: async (path: string): Promise => { + forPath: (path: string): Promise => { + const now = Date.now(); const cached = cache.get(path); - if (cached && cached.expiresAt > Date.now()) return cached.value; - try { - const value = await load(path); - cache.set(path, { expiresAt: Date.now() + CACHE_TTL_MS, value }); - return value; - } catch { - return cached?.value ?? empty(path, "unavailable"); - } + if (cached && now - cached.loadedAt < CACHE_TTL_MS) return Promise.resolve(cached.value); + const inFlight = pending.get(path); + if (inFlight) return inFlight; + const next = load(path) + .then((value) => { + for (const [key, entry] of cache) { + if (now - entry.loadedAt >= STALE_TTL_MS) cache.delete(key); + } + cache.set(path, { loadedAt: Date.now(), value }); + return value; + }) + .catch(() => + cached && now - cached.loadedAt < STALE_TTL_MS + ? cached.value + : empty(path, "unavailable"), + ) + .finally(() => pending.delete(path)); + pending.set(path, next); + return next; }, }; } diff --git a/api/tests/unit/nearbuilders.test.ts b/api/tests/unit/nearbuilders.test.ts new file mode 100644 index 000000000..970b44ea3 --- /dev/null +++ b/api/tests/unit/nearbuilders.test.ts @@ -0,0 +1,111 @@ +import { Effect } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { communityPath, createNearbuilders } from "../../src/services/nearbuilders"; + +const BASE = "https://nearbuilders.example"; + +function page(data: unknown[]) { + return Response.json({ data, meta: { total: data.length, hasMore: false, nextCursor: null } }); +} + +function project(slug: string, cityNode: string, logoUrl: string | null = null) { + return { kind: "project", slug, title: `Project ${slug}`, cityNode, logoUrl }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("communityPath", () => { + const nodes: Record = { + state: { slug: "Testland", parentId: null }, + city: { slug: "sample-city", parentId: "state" }, + orphan: { slug: "lost-city", parentId: "missing" }, + loop: { slug: "loop", parentId: "loop" }, + }; + const getNode = (id: string) => Effect.succeed(nodes[id] ?? null); + + it("joins parent slugs from the root down", async () => { + expect(await Effect.runPromise(communityPath("city", getNode))).toBe("testland/sample-city"); + expect(await Effect.runPromise(communityPath("state", getNode))).toBe("testland"); + }); + + it("returns null when the chain doesn't reach a root", async () => { + expect(await Effect.runPromise(communityPath("orphan", getNode))).toBeNull(); + expect(await Effect.runPromise(communityPath("loop", getNode))).toBeNull(); + expect(await Effect.runPromise(communityPath("unknown", getNode))).toBeNull(); + }); +}); + +describe("createNearbuilders", () => { + it("keeps only https logos", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: URL) => + input.pathname === "/api/v1/projects" + ? page([ + project("secure", "testland", "https://example.com/a.png"), + project("plain", "testland", "http://example.com/b.png"), + ]) + : page([]), + ), + ); + const result = await createNearbuilders(BASE).forPath("testland"); + expect(result.projects.items.map((item) => item.logoUrl)).toEqual([ + "https://example.com/a.png", + null, + ]); + }); + + it("counts the server total when every item is tagged, even if one fails to parse", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: URL) => + input.pathname === "/api/v1/projects" + ? Response.json({ + data: [project("good", "testland"), { cityNode: "testland", title: 42 }], + meta: { total: 30, hasMore: true, nextCursor: "next" }, + }) + : page([]), + ), + ); + const result = await createNearbuilders(BASE).forPath("testland"); + expect(result.projects).toMatchObject({ total: 30, items: [{ title: "Project good" }] }); + }); + + it("shares one request between concurrent loads", async () => { + const fetchMock = vi.fn(async () => page([])); + vi.stubGlobal("fetch", fetchMock); + const nearbuilders = createNearbuilders(BASE); + await Promise.all([nearbuilders.forPath("testland"), nearbuilders.forPath("testland")]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("serves the last good result when nearbuilders.org fails after the cache expires", async () => { + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + let down = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: URL) => { + if (down) return new Response("Bad gateway", { status: 502 }); + return input.pathname === "/api/v1/projects" + ? page([project("kept", "testland")]) + : page([]); + }), + ); + const nearbuilders = createNearbuilders(BASE); + expect((await nearbuilders.forPath("testland")).status).toBe("ok"); + + down = true; + now += 5 * 60 * 1000; + expect(await nearbuilders.forPath("testland")).toMatchObject({ + status: "ok", + projects: { items: [{ title: "Project kept" }] }, + }); + + now += 2 * 60 * 60 * 1000; + expect((await nearbuilders.forPath("testland")).status).toBe("unavailable"); + }); +}); diff --git a/ui/src/routes/_public/n/-nearbuilders-section.tsx b/ui/src/routes/_public/n/-nearbuilders-section.tsx index 31cf02306..a97635245 100644 --- a/ui/src/routes/_public/n/-nearbuilders-section.tsx +++ b/ui/src/routes/_public/n/-nearbuilders-section.tsx @@ -37,7 +37,6 @@ export function NearbuildersSection({ nodeId, nodeName }: { nodeId: string; node const unavailable = isError || data?.status === "unavailable"; const builders = data?.builders.items ?? []; const projects = data?.projects.items ?? []; - const joinUrl = `${data?.baseUrl ?? "https://nearbuilders.org"}/join`; return (
    @@ -70,7 +69,7 @@ export function NearbuildersSection({ nodeId, nodeName }: { nodeId: string; node render={(props) => ( Date: Tue, 27 Oct 2026 03:50:45 +0530 Subject: [PATCH 3/4] fix: back off for 30s after nearbuilders.org fails --- api/src/services/nearbuilders.ts | 18 +++++++++++++----- api/tests/unit/nearbuilders.test.ts | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/api/src/services/nearbuilders.ts b/api/src/services/nearbuilders.ts index c401738de..a73165a15 100644 --- a/api/src/services/nearbuilders.ts +++ b/api/src/services/nearbuilders.ts @@ -5,6 +5,7 @@ export const NEARBUILDERS_PREVIEW_LIMIT = 6; const REQUEST_TIMEOUT_MS = 5000; const CACHE_TTL_MS = 2 * 60 * 1000; const STALE_TTL_MS = 60 * 60 * 1000; +const FAILURE_BACKOFF_MS = 30 * 1000; const MAX_DEPTH = 10; const builderSchema = z.object({ @@ -113,6 +114,7 @@ export function createNearbuilders(baseUrlInput: string) { const baseUrl = normalizeBaseUrl(baseUrlInput); const cache = new Map(); const pending = new Map>(); + const failures = new Map(); async function request(path: string, params: Record) { const url = new URL(`/api${path}`, baseUrl); @@ -183,21 +185,27 @@ export function createNearbuilders(baseUrlInput: string) { const now = Date.now(); const cached = cache.get(path); if (cached && now - cached.loadedAt < CACHE_TTL_MS) return Promise.resolve(cached.value); + const fallback = () => + cached && now - cached.loadedAt < STALE_TTL_MS ? cached.value : empty(path, "unavailable"); + const failedAt = failures.get(path); + if (failedAt !== undefined && now - failedAt < FAILURE_BACKOFF_MS) { + return Promise.resolve(fallback()); + } const inFlight = pending.get(path); if (inFlight) return inFlight; const next = load(path) .then((value) => { + failures.delete(path); for (const [key, entry] of cache) { if (now - entry.loadedAt >= STALE_TTL_MS) cache.delete(key); } cache.set(path, { loadedAt: Date.now(), value }); return value; }) - .catch(() => - cached && now - cached.loadedAt < STALE_TTL_MS - ? cached.value - : empty(path, "unavailable"), - ) + .catch(() => { + failures.set(path, Date.now()); + return fallback(); + }) .finally(() => pending.delete(path)); pending.set(path, next); return next; diff --git a/api/tests/unit/nearbuilders.test.ts b/api/tests/unit/nearbuilders.test.ts index 970b44ea3..97407753d 100644 --- a/api/tests/unit/nearbuilders.test.ts +++ b/api/tests/unit/nearbuilders.test.ts @@ -108,4 +108,22 @@ describe("createNearbuilders", () => { now += 2 * 60 * 60 * 1000; expect((await nearbuilders.forPath("testland")).status).toBe("unavailable"); }); + + it("waits 30 seconds before asking nearbuilders.org again after a failure", async () => { + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + const fetchMock = vi.fn(async () => new Response("Bad gateway", { status: 502 })); + vi.stubGlobal("fetch", fetchMock); + const nearbuilders = createNearbuilders(BASE); + expect((await nearbuilders.forPath("testland")).status).toBe("unavailable"); + expect(fetchMock).toHaveBeenCalledTimes(2); + + now += 10 * 1000; + expect((await nearbuilders.forPath("testland")).status).toBe("unavailable"); + expect(fetchMock).toHaveBeenCalledTimes(2); + + now += 30 * 1000; + await nearbuilders.forPath("testland"); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); }); From 204e881b5c2c889ee71d6a86f0c117f3e8b5b372 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:11:44 +0530 Subject: [PATCH 4/4] refactor: follow repo standards in the nearbuilders section Use Effect.fn for communityPath, derive the result type from the contract schema, validate nearbuildersUrl in the config schema instead of falling back silently, and tidy names and imports in the section. --- api/src/index.ts | 2 +- api/src/services/nearbuilders.ts | 75 +++++++------------ .../_public/n/-nearbuilders-section.tsx | 17 ++--- 3 files changed, 34 insertions(+), 60 deletions(-) diff --git a/api/src/index.ts b/api/src/index.ts index 402883be0..457e2a60c 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -315,7 +315,7 @@ export default createPlugin.withPlugins()({ .default("") .describe("Repository URL included in the Nominatim User-Agent"), nearbuildersUrl: z - .string() + .url({ protocol: /^https?$/ }) .default("https://nearbuilders.org") .describe("NEAR Builders base URL for community builders and projects"), }), diff --git a/api/src/services/nearbuilders.ts b/api/src/services/nearbuilders.ts index a73165a15..87dacc458 100644 --- a/api/src/services/nearbuilders.ts +++ b/api/src/services/nearbuilders.ts @@ -1,5 +1,6 @@ import { Effect } from "effect"; import { z } from "zod"; +import type { communityBuildersSchema } from "../discovery-contract"; export const NEARBUILDERS_PREVIEW_LIMIT = 6; const REQUEST_TIMEOUT_MS = 5000; @@ -29,56 +30,30 @@ const pageSchema = z.object({ meta: z.object({ total: z.number().int().nonnegative() }).optional(), }); -export interface CommunityBuilder { - nearAccount: string; - name: string | null; - url: string; -} - -export interface CommunityProject { - title: string; - description: string | null; - logoUrl: string | null; - url: string; -} +export type CommunityBuilders = z.infer; -export interface CommunityBuilders { - path: string; - baseUrl: string; - status: "ok" | "unavailable"; - builders: { items: CommunityBuilder[]; total: number; url: string }; - projects: { items: CommunityProject[]; total: number; url: string }; +interface NodeLink { + slug: string; + parentId: string | null; } -export function normalizeBaseUrl(value: string) { - try { - const url = new URL(value); - if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("protocol"); - return url.origin; - } catch { - return "https://nearbuilders.org"; - } -} - -export function communityPath( +export const communityPath = Effect.fn("communityPath")(function* ( nodeId: string, - getNode: (id: string) => Effect.Effect<{ slug: string; parentId: string | null } | null, E>, + getNode: (id: string) => Effect.Effect, ) { - return Effect.gen(function* () { - const slugs: string[] = []; - const seen = new Set(); - let current: string | null = nodeId; - while (current) { - if (seen.has(current) || slugs.length >= MAX_DEPTH) return null; - seen.add(current); - const node: { slug: string; parentId: string | null } | null = yield* getNode(current); - if (!node) return null; - slugs.push(node.slug.toLowerCase()); - current = node.parentId; - } - return slugs.reverse().join("/"); - }); -} + const slugs: string[] = []; + const seen = new Set(); + let current: string | null = nodeId; + while (current) { + if (seen.has(current) || slugs.length >= MAX_DEPTH) return null; + seen.add(current); + const node: NodeLink | null = yield* getNode(current); + if (!node) return null; + slugs.push(node.slug.toLowerCase()); + current = node.parentId; + } + return slugs.reverse().join("/"); +}); function httpsUrl(value: string | null | undefined) { if (!value) return null; @@ -111,7 +86,7 @@ function matchedPage( } export function createNearbuilders(baseUrlInput: string) { - const baseUrl = normalizeBaseUrl(baseUrlInput); + const baseUrl = new URL(baseUrlInput).origin; const cache = new Map(); const pending = new Map>(); const failures = new Map(); @@ -127,7 +102,7 @@ export function createNearbuilders(baseUrlInput: string) { return response.json(); } - function empty(path: string, status: CommunityBuilders["status"]): CommunityBuilders { + function baseResult(path: string, status: CommunityBuilders["status"]): CommunityBuilders { return { path, baseUrl, @@ -154,7 +129,7 @@ export function createNearbuilders(baseUrlInput: string) { path, (project) => !project.visibility || project.visibility === "public", ); - const result = empty(path, "ok"); + const result = baseResult(path, "ok"); return { ...result, builders: { @@ -186,7 +161,9 @@ export function createNearbuilders(baseUrlInput: string) { const cached = cache.get(path); if (cached && now - cached.loadedAt < CACHE_TTL_MS) return Promise.resolve(cached.value); const fallback = () => - cached && now - cached.loadedAt < STALE_TTL_MS ? cached.value : empty(path, "unavailable"); + cached && now - cached.loadedAt < STALE_TTL_MS + ? cached.value + : baseResult(path, "unavailable"); const failedAt = failures.get(path); if (failedAt !== undefined && now - failedAt < FAILURE_BACKOFF_MS) { return Promise.resolve(fallback()); diff --git a/ui/src/routes/_public/n/-nearbuilders-section.tsx b/ui/src/routes/_public/n/-nearbuilders-section.tsx index a97635245..383d0b5cd 100644 --- a/ui/src/routes/_public/n/-nearbuilders-section.tsx +++ b/ui/src/routes/_public/n/-nearbuilders-section.tsx @@ -7,8 +7,7 @@ import { import { queryOptions, useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { type ApiClient, useApiClient, useAuthClient } from "@/app"; -import { Avatar, AvatarFallback, AvatarImage, Button, SectionHeader } from "@/components"; -import { Skeleton } from "@/components/ui/skeleton"; +import { Avatar, AvatarFallback, AvatarImage, Button, SectionHeader, Skeleton } from "@/components"; import { useAppTranslation } from "@/i18n/runtime"; import { getNearInitials, resolveNearImageUrl } from "@/lib/near-profile"; @@ -19,11 +18,10 @@ type Project = CommunityBuilders["projects"]["items"][number]; const cardClassName = "group flex h-full min-h-16 items-center gap-4 rounded-2xl border border-border bg-card p-4 outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"; -export function communityBuildersQueryOptions(apiClient: ApiClient, nodeId: string) { +function communityBuildersQueryOptions(apiClient: ApiClient, nodeId: string) { return queryOptions({ queryKey: ["community-builders", nodeId], queryFn: () => apiClient.getCommunityBuilders({ nodeId }), - enabled: !!nodeId, retry: false, staleTime: 60_000, }); @@ -83,7 +81,7 @@ export function NearbuildersSection({ nodeId, nodeName }: { nodeId: string; node ) : (
    - builders.length ? data.builders.url : null} @@ -94,8 +92,8 @@ export function NearbuildersSection({ nodeId, nodeName }: { nodeId: string; node ))} - - + projects.length ? data.projects.url : null} @@ -106,7 +104,7 @@ export function NearbuildersSection({ nodeId, nodeName }: { nodeId: string; node ))} - +
    )}
    @@ -122,7 +120,7 @@ function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) { ); } -function Group({ +function NearbuildersList({ title, empty, seeAllUrl, @@ -170,7 +168,6 @@ function BuilderCard({ builder }: { builder: Builder }) { return data ?? null; }, staleTime: 5 * 60 * 1000, - retry: false, }); const name = builder.name || profile?.name || builder.nearAccount; const avatarUrl = resolveNearImageUrl(profile?.image);