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 && ( + + )} +
+ } + /> + +
+
+ ); +}