From d68b96e6702a0bab85a30c1fada6ba94317b4193 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:49:17 +0530 Subject: [PATCH] feat(me): add Your activity, the member's own page with their events (#400) A normal City Node route at /me: the member's avatar and name, links to edit the profile in Settings and to their public profile, and the events they checked in at across every community. Reachable from the account menu. --- .changeset/member-activity-page.md | 5 + ui/src/components/attendance-record.test.tsx | 11 +++ ui/src/components/attendance-record.tsx | 8 +- ui/src/components/layout/breadcrumbs.ts | 2 + ui/src/components/layout/nav-items.test.ts | 1 + ui/src/components/layout/user-nav-menu.tsx | 12 ++- ui/src/i18n/messages/common.ts | 9 ++ .../_authenticated/_dashboard/-me.test.tsx | 97 +++++++++++++++++++ .../routes/_authenticated/_dashboard/me.tsx | 84 ++++++++++++++++ 9 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 .changeset/member-activity-page.md create mode 100644 ui/src/routes/_authenticated/_dashboard/-me.test.tsx create mode 100644 ui/src/routes/_authenticated/_dashboard/me.tsx diff --git a/.changeset/member-activity-page.md b/.changeset/member-activity-page.md new file mode 100644 index 00000000..386200e1 --- /dev/null +++ b/.changeset/member-activity-page.md @@ -0,0 +1,5 @@ +--- +"ui": patch +--- + +Add Your activity (/me): the member's profile, with links to edit it in Settings, and the events they checked in at across every community. diff --git a/ui/src/components/attendance-record.test.tsx b/ui/src/components/attendance-record.test.tsx index 0474b353..551b24a8 100644 --- a/ui/src/components/attendance-record.test.tsx +++ b/ui/src/components/attendance-record.test.tsx @@ -191,6 +191,17 @@ describe("AttendanceRecord", () => { expect(screen.queryByTestId("home")).toBeNull(); }); + it("keeps every community's section, with loading and empty states, when asked to", async () => { + let resolve: (value: unknown) => void = () => {}; + harness.getMyAttendance = () => new Promise((done) => (resolve = done)); + show({ alwaysShow: true, testId: "me" }); + expect(screen.getByTestId("me-loading")).toBeTruthy(); + + resolve({ events: [], unclassified: [], communities: [] }); + expect((await screen.findByTestId("me-empty")).textContent).toBe("No check-ins recorded yet."); + expect(screen.getByTestId("me")).toBeTruthy(); + }); + it("hides itself when the caller isn't a member of the community", async () => { harness.getMyAttendance = async () => { throw new ORPCError("FORBIDDEN", { message: "You are not a member of this community" }); diff --git a/ui/src/components/attendance-record.tsx b/ui/src/components/attendance-record.tsx index 70a3aec1..ed7d08e6 100644 --- a/ui/src/components/attendance-record.tsx +++ b/ui/src/components/attendance-record.tsx @@ -32,10 +32,13 @@ const REASON_MESSAGES = { export function AttendanceRecord({ organizationId = null, reconcile = false, + alwaysShow = false, testId, }: { organizationId?: string | null; reconcile?: boolean; + /** Keep the section, with its loading and empty states, when showing every community. */ + alwaysShow?: boolean; testId: string; }) { const translate = useAppTranslation(); @@ -55,11 +58,12 @@ export function AttendanceRecord({ void queryClient.invalidateQueries({ queryKey: attendanceQueryKeys.records() }); }, [queryClient, reconciledAt]); const everyCommunity = !organizationId; + const hideWhenEmpty = everyCommunity && !alwaysShow; if (record.error instanceof ORPCError && record.error.code === "FORBIDDEN") return null; - if (everyCommunity && record.isPending) return null; + if (hideWhenEmpty && record.isPending) return null; if ( - everyCommunity && + hideWhenEmpty && record.data && record.data.communities.length === 0 && record.data.events.length === 0 && diff --git a/ui/src/components/layout/breadcrumbs.ts b/ui/src/components/layout/breadcrumbs.ts index b123d739..0aaa269b 100644 --- a/ui/src/components/layout/breadcrumbs.ts +++ b/ui/src/components/layout/breadcrumbs.ts @@ -122,6 +122,8 @@ export function crumbsFor( return [page(t("dashboard.startCommunity"))]; case "discover": return [page(t("nav.directory"))]; + case "me": + return [page(t("nav.activity"))]; case "orgs": { if (!second) return [page(ORGS.label)]; if (second === "new") return [ORGS, page(t("nav.newOrganization"))]; diff --git a/ui/src/components/layout/nav-items.test.ts b/ui/src/components/layout/nav-items.test.ts index 325726ab..2b660940 100644 --- a/ui/src/components/layout/nav-items.test.ts +++ b/ui/src/components/layout/nav-items.test.ts @@ -279,6 +279,7 @@ describe("breadcrumbs", () => { it("names pages instead of echoing path segments", () => { expect(labels("/dashboard")).toEqual(["Home"]); expect(labels("/discover")).toEqual(["Directory"]); + expect(labels("/me")).toEqual(["Your activity"]); expect(labels("/dashboard/node/proposals")).toEqual(["My community", "Proposals"]); expect(labels("/nodes/abc/content")).toEqual(["My community", "Events"]); expect( diff --git a/ui/src/components/layout/user-nav-menu.tsx b/ui/src/components/layout/user-nav-menu.tsx index 5959dc33..685fd0be 100644 --- a/ui/src/components/layout/user-nav-menu.tsx +++ b/ui/src/components/layout/user-nav-menu.tsx @@ -1,4 +1,10 @@ -import { GearIcon, SignOutIcon, UserCircleIcon, UserIcon } from "@phosphor-icons/react"; +import { + CalendarCheckIcon, + GearIcon, + SignOutIcon, + UserCircleIcon, + UserIcon, +} from "@phosphor-icons/react"; import { Link } from "@tanstack/react-router"; import type { Organization } from "@/app"; import { pluginPath } from "@/app"; @@ -62,6 +68,10 @@ export function UserNavMenuContent({ + } data-testid="account.activity-menuitem"> + + {translate("nav.activity")} + {nearAccountId && ( } diff --git a/ui/src/i18n/messages/common.ts b/ui/src/i18n/messages/common.ts index 3c3507d4..4e5fa07d 100644 --- a/ui/src/i18n/messages/common.ts +++ b/ui/src/i18n/messages/common.ts @@ -1266,6 +1266,14 @@ export const commonMessages = createMessageCatalogs(["en", "es", "fr", "zh"], { "label.voteAbstain": ["Abstain", "Abstenerse", "Abstention", "弃权"], "label.voteAgainst": ["Against", "En contra", "Contre", "反对"], "label.voteFor": ["For", "A favor", "Pour", "赞成"], + "me.description": [ + "Your profile and the events you checked in at, across every community.", + "Tu perfil y los eventos en los que te registraste, en todas tus comunidades.", + "Votre profil et les événements auxquels vous vous êtes enregistré, dans toutes vos communautés.", + "你的资料,以及你在所有社区签到过的活动。", + ], + "me.editProfile": ["Edit profile", "Editar perfil", "Modifier le profil", "编辑资料"], + "me.publicProfile": ["Public profile", "Perfil público", "Profil public", "公开资料"], "measurement.copyLink": ["Copied a link", "Copió un enlace", "Copie d’un lien", "复制链接"], "measurement.openCommunity": [ "Opened a community", @@ -1291,6 +1299,7 @@ export const commonMessages = createMessageCatalogs(["en", "es", "fr", "zh"], { "Ouverture d’Explorer", "打开探索页面", ], + "nav.activity": ["Your activity", "Tu actividad", "Votre activité", "你的动态"], "nav.admin": ["Admin", "Administración", "Administration", "管理"], "nav.apiKeys": ["API keys", "Claves API", "Clés API", "API 密钥"], "nav.community": ["Community", "Comunidad", "Communauté", "社区"], diff --git a/ui/src/routes/_authenticated/_dashboard/-me.test.tsx b/ui/src/routes/_authenticated/_dashboard/-me.test.tsx new file mode 100644 index 00000000..40b79ae6 --- /dev/null +++ b/ui/src/routes/_authenticated/_dashboard/-me.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "@/i18n/test-render"; +import { MePage } from "./me"; + +const identity = vi.hoisted(() => ({ + nearAccountId: null as string | null, + avatarSrc: undefined as string | undefined, + displayName: "Ada", + handle: "ada@example.com", + showHandle: true, + initials: "A", +})); + +vi.mock("@/components/layout/use-identity", () => ({ useIdentity: () => identity })); + +vi.mock("@/app", () => ({ + pluginPath: (path: string) => path, + useApiClient: () => ({ + getMyAttendance: async () => ({ + events: [ + { + organizationId: "org-north", + communityName: "North", + eventId: "e1", + eventName: "Builders Night", + checkedInAt: "2026-10-10T18:30:00.000Z", + startsAt: null, + }, + ], + unclassified: [], + communities: [], + }), + }), +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + Link: ({ + to, + params, + children, + ...rest + }: { + to: string; + params?: Record; + children?: ReactNode; + }) => ( + href.replace(`$${key}`, value), + to, + )} + {...rest} + > + {children} + + ), +})); + +function show() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + identity.nearAccountId = null; +}); + +describe("the member's own page", () => { + it("shows who they are, where to edit the profile, and their events across communities", async () => { + show(); + expect(screen.getByTestId("me.header").textContent).toContain("Ada"); + expect(screen.getByText("ada@example.com")).toBeTruthy(); + expect(screen.getByTestId("me.edit-profile").getAttribute("href")).toBe("/settings/profile"); + expect(screen.queryByTestId("me.public-profile")).toBeNull(); + + const row = await screen.findByTestId("me.attendance-event"); + expect(row.textContent).toContain("Builders Night"); + expect(row.textContent).toContain("North"); + }); + + it("links the public profile when the member has a NEAR account", () => { + identity.nearAccountId = "builder.testnet"; + show(); + expect(screen.getByTestId("me.public-profile").getAttribute("href")).toBe("/builder.testnet"); + }); +}); diff --git a/ui/src/routes/_authenticated/_dashboard/me.tsx b/ui/src/routes/_authenticated/_dashboard/me.tsx new file mode 100644 index 00000000..5451eb12 --- /dev/null +++ b/ui/src/routes/_authenticated/_dashboard/me.tsx @@ -0,0 +1,84 @@ +import { GearIcon, UserCircleIcon, UserIcon } from "@phosphor-icons/react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { pluginPath } from "@/app"; +import { AttendanceRecord } from "@/components/attendance-record"; +import { PageContainer } from "@/components/layout/page-container"; +import { PageHeader } from "@/components/layout/page-header"; +import { useIdentity } from "@/components/layout/use-identity"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { resolveAppLocale, translateAppMessage, useAppTranslation } from "@/i18n/runtime"; +import { pageTitle } from "@/lib/page-title"; + +export const Route = createFileRoute("/_authenticated/_dashboard/me")({ + head: ({ match }) => ({ + meta: [ + { + title: pageTitle( + translateAppMessage( + "nav.activity", + undefined, + resolveAppLocale(undefined, match.context.locale), + ), + match.context.runtimeConfig, + ), + }, + ], + }), + component: MePage, +}); + +/** + * The member's own page: who they are, with links to edit the profile the auth + * plugin keeps in Settings, and the events they checked in at across communities. + */ +export function MePage() { + const translate = useAppTranslation(); + const { nearAccountId, avatarSrc, displayName, handle, showHandle, initials } = useIdentity(); + + return ( + +
+ + + {avatarSrc ? : null} + {initials || } + + {displayName} + + } + subtitle={showHandle ? handle : undefined} + description={translate("me.description")} + actions={ +
+ + {nearAccountId && ( + + )} +
+ } + /> + +
+
+ ); +}