diff --git a/api/src/discovery-contract.ts b/api/src/discovery-contract.ts index e70312ed..e863f7a0 100644 --- a/api/src/discovery-contract.ts +++ b/api/src/discovery-contract.ts @@ -36,6 +36,30 @@ export type DiscoveryProfile = z.infer; export type StoredDiscoveryProfile = DiscoveryProfile & { boundary?: z.infer | null; }; +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(), @@ -280,6 +304,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 aa94903f..e279dc8c 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -29,6 +29,7 @@ import { type MemberDirectoryService, MemberDirectoryTag, } from "./services/member-directory"; +import { communityPath, createNearbuilders } from "./services/nearbuilders"; import type { NodeEffect, NodeRecord, NodesService } from "./services/nodes"; import { NodesLive, NodesTag } from "./services/nodes"; import { @@ -81,6 +82,7 @@ class ApiServices extends Context.Service< validators: ValidatorsService; discovery: DiscoveryService; storage: StorageService; + nearbuilders: ReturnType; memberDirectory: MemberDirectoryService; attendance: AttendanceService; teamJoinRequests: TeamJoinRequestsService; @@ -390,6 +392,10 @@ export default createPlugin.withPlugins()({ .string() .default("") .describe("Repository URL included in the Nominatim User-Agent"), + nearbuildersUrl: z + .url({ protocol: /^https?$/ }) + .default("https://nearbuilders.org") + .describe("NEAR Builders base URL for community builders and projects"), mainnetStakingPoolWhitelist: z .string() .default("lockup-whitelist.near") @@ -446,6 +452,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), memberDirectory: Context.get(services, MemberDirectoryTag), attendance: Context.get(services, AttendanceTag), teamJoinRequests: Context.get(services, TeamJoinRequestsTag), @@ -890,6 +897,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 00000000..87dacc45 --- /dev/null +++ b/api/src/services/nearbuilders.ts @@ -0,0 +1,191 @@ +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; +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({ + 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 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(), +}); + +export type CommunityBuilders = z.infer; + +interface NodeLink { + slug: string; + parentId: string | null; +} + +export const communityPath = Effect.fn("communityPath")(function* ( + nodeId: string, + getNode: (id: string) => Effect.Effect, +) { + 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; + try { + const url = new URL(value); + return url.protocol === "https:" ? url.href : null; + } catch { + return null; + } +} + +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 && parsed.data.cityNode === path && matches(parsed.data) + ? [parsed.data] + : []; + }); + 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 = new URL(baseUrlInput).origin; + 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); + 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 baseResult(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, + path, + (builder) => !builder.withdrawnAt, + ); + const projects = matchedPage( + projectPage, + projectSchema, + path, + (project) => !project.visibility || project.visibility === "public", + ); + const result = baseResult(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: httpsUrl(project.logoUrl), + url: `${baseUrl}/projects/${encodeURIComponent(project.kind)}/${encodeURIComponent(project.slug)}`, + })), + }, + }; + } + + return { + baseUrl, + forPath: (path: string): Promise => { + 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 + : baseResult(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(() => { + failures.set(path, Date.now()); + return fallback(); + }) + .finally(() => pending.delete(path)); + pending.set(path, next); + return next; + }, + }; +} diff --git a/api/tests/integration/community-builders.test.ts b/api/tests/integration/community-builders.test.ts new file mode 100644 index 00000000..76737fc8 --- /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/api/tests/unit/nearbuilders.test.ts b/api/tests/unit/nearbuilders.test.ts new file mode 100644 index 00000000..97407753 --- /dev/null +++ b/api/tests/unit/nearbuilders.test.ts @@ -0,0 +1,129 @@ +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"); + }); + + 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); + }); +}); diff --git a/bos.app.ts b/bos.app.ts index c522b80b..937e84ed 100644 --- a/bos.app.ts +++ b/bos.app.ts @@ -29,6 +29,7 @@ export default App({ platformAccount: "v1.citynode.near", domain: "citynode.app", gatewayDomains: "citynode.app,testnet.citynode.app", + nearbuildersUrl: "https://nearbuilders.org", mainnetStakingPoolWhitelist: "lockup-whitelist.near", testnetStakingPoolWhitelist: "whitelist.f863973.m0", }, diff --git a/ui/src/i18n/messages/public.ts b/ui/src/i18n/messages/public.ts index 793e2d21..d8faf495 100644 --- a/ui/src/i18n/messages/public.ts +++ b/ui/src/i18n/messages/public.ts @@ -966,6 +966,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 405b7304..e07f7ffd 100644 --- a/ui/src/routes/_public/n/$slug.tsx +++ b/ui/src/routes/_public/n/$slug.tsx @@ -30,6 +30,7 @@ import { stakingValidatorsQueryOptions, } from "@/lib/queries/nodes"; import { socialMeta } from "@/lib/social-meta"; +import { NearbuildersSection } from "./-nearbuilders-section"; import { ProposeHomepageCta } from "./-propose-homepage-cta"; export const Route = createFileRoute("/_public/n/$slug")({ @@ -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 00000000..383d0b5c --- /dev/null +++ b/ui/src/routes/_public/n/-nearbuilders-section.tsx @@ -0,0 +1,208 @@ +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, Skeleton } from "@/components"; +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"; + +function communityBuildersQueryOptions(apiClient: ApiClient, nodeId: string) { + return queryOptions({ + queryKey: ["community-builders", nodeId], + queryFn: () => apiClient.getCommunityBuilders({ 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 ?? []; + + 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 NearbuildersList({ + 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, + }); + 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 57f1a081..47e7b3a5 100644 --- a/ui/src/routes/_public/n/-node-page.test.tsx +++ b/ui/src/routes/_public/n/-node-page.test.tsx @@ -64,7 +64,22 @@ afterEach(() => { listings.clear(); }); -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 = { @@ -115,6 +130,8 @@ function mockApi(nodes = [parent, child], ownPoolNodeId?: string) { if (listing === "fail") return Response.json({ json: null }, { status: 500 }); return Response.json({ json: listing ?? 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": @@ -343,6 +360,75 @@ it("offers a way back to Explore when the community does not exist", async () => 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(); +}); + it("shows the community's last observed member count with its observation time", async () => { mockApi(); memberCounts.set(child.id, { count: 42, observedAt: "2026-10-20T10:00:00.000Z" });