From 34335843d14665ea7083615ab808f55a7bc24265 Mon Sep 17 00:00:00 2001 From: KazanderDad <98373366+KazanderDad@users.noreply.github.com> Date: Sun, 9 Nov 2025 15:57:44 -0500 Subject: [PATCH 1/4] feat(frontend): add moonbeam indexer page --- frontend/__tests__/app-header.test.tsx | 5 +- frontend/__tests__/auth-provider.test.tsx | 2 + frontend/__tests__/home-page.test.tsx | 8 +- frontend/__tests__/indexer-page.test.tsx | 14 ++ frontend/__tests__/profile-page.test.tsx | 4 + frontend/__tests__/results-page.test.tsx | 37 ++++- frontend/__tests__/scan-camera-page.test.tsx | 9 +- frontend/__tests__/scan-qr-page.test.tsx | 4 + frontend/package.json | 1 + frontend/src/app/(routes)/circle/page.tsx | 139 +++++++++++------- frontend/src/app/(routes)/indexer/page.tsx | 27 ++++ frontend/src/app/(routes)/new-user/page.tsx | 47 +++--- frontend/src/app/(routes)/profile/page.tsx | 45 ++++-- frontend/src/app/(routes)/results/page.tsx | 11 ++ .../src/app/(routes)/scan/camera/page.tsx | 70 +++++++-- frontend/src/app/(routes)/scan/my-qr/page.tsx | 27 ++-- frontend/src/app/(routes)/signin/page.tsx | 4 +- frontend/src/app/(routes)/vouch/page.tsx | 11 ++ frontend/src/app/page.tsx | 16 +- frontend/src/components/AppFooter.tsx | 1 + frontend/src/components/AppHeader.tsx | 3 + frontend/src/components/AuthProvider.tsx | 8 +- frontend/src/components/QRScanner.tsx | 86 ++++++++++- frontend/src/lib/onboarding.ts | 57 +++++++ frontend/src/lib/store.ts | 6 +- pnpm-lock.yaml | 11 ++ 26 files changed, 525 insertions(+), 128 deletions(-) create mode 100644 frontend/__tests__/indexer-page.test.tsx create mode 100644 frontend/src/app/(routes)/indexer/page.tsx create mode 100644 frontend/src/lib/onboarding.ts diff --git a/frontend/__tests__/app-header.test.tsx b/frontend/__tests__/app-header.test.tsx index 072e0d4..5d06556 100644 --- a/frontend/__tests__/app-header.test.tsx +++ b/frontend/__tests__/app-header.test.tsx @@ -32,7 +32,9 @@ describe("AppHeader", () => { act(() => { useUserStore.setState({ session, - user: { user_id: "1", display_name: "Sky Trail", cubid_id: "sky" }, + user: { user_id: "1", display_name: "Sky Trail", cubid_id: "sky", evm_address: "0x123" }, + walletAddress: null, + initialised: true, }); }); @@ -42,6 +44,7 @@ describe("AppHeader", () => { expect(screen.getByRole("link", { name: /My QR code/i })).toHaveAttribute("href", "/scan/my-qr"); expect(screen.getByRole("link", { name: /Camera/i })).toHaveAttribute("href", "/scan/camera"); expect(screen.getByRole("link", { name: /My Circle/i })).toHaveAttribute("href", "/circle"); + expect(screen.getByRole("link", { name: /Indexer/i })).toHaveAttribute("href", "/indexer"); expect(screen.getByText("Sky Trail")).toBeInTheDocument(); expect(screen.getByText("ST")).toBeInTheDocument(); }); diff --git a/frontend/__tests__/auth-provider.test.tsx b/frontend/__tests__/auth-provider.test.tsx index 461a8ea..c075e6f 100644 --- a/frontend/__tests__/auth-provider.test.tsx +++ b/frontend/__tests__/auth-provider.test.tsx @@ -56,6 +56,7 @@ describe("AuthProvider", () => { expect(useUserStore.getState().session).toBe(session); }); expect(useUserStore.getState().user).toEqual(profile); + expect(useUserStore.getState().initialised).toBe(true); expect(fetchMyProfileMock).toHaveBeenCalledTimes(1); fetchMyProfileMock.mockResolvedValueOnce(null); @@ -87,6 +88,7 @@ describe("AuthProvider", () => { expect(useUserStore.getState().session).toBeNull(); expect(useUserStore.getState().user).toBeNull(); + expect(useUserStore.getState().initialised).toBe(true); expect(fetchMyProfileMock).not.toHaveBeenCalled(); errorSpy.mockRestore(); diff --git a/frontend/__tests__/home-page.test.tsx b/frontend/__tests__/home-page.test.tsx index c135d02..180ef68 100644 --- a/frontend/__tests__/home-page.test.tsx +++ b/frontend/__tests__/home-page.test.tsx @@ -29,6 +29,9 @@ describe("Home page", () => { const ctas = screen.getAllByRole("link", { name: /Start verifying now/i }); expect(ctas).toHaveLength(2); ctas.forEach((cta) => expect(cta).toHaveAttribute("href", "/signin")); + + const indexerLink = screen.getByRole("link", { name: /Visit Moonbeam indexer/i }); + expect(indexerLink).toHaveAttribute("href", "/indexer"); }); it("shows quick actions when the user is signed in", () => { @@ -40,7 +43,9 @@ describe("Home page", () => { act(() => { useUserStore.setState({ session, - user: { user_id: "1", display_name: "Agent Maple", cubid_id: "maple" }, + user: { user_id: "1", display_name: "Agent Maple", cubid_id: "maple", evm_address: "0x123" }, + walletAddress: null, + initialised: true, }); }); @@ -49,5 +54,6 @@ describe("Home page", () => { expect(screen.getByText(/let’s keep building trusted links/i)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /Share your QR/i })).toHaveAttribute("href", "/scan/my-qr"); expect(screen.getByRole("link", { name: /Open camera/i })).toHaveAttribute("href", "/scan/camera"); + expect(screen.getByRole("link", { name: /Open Moonbeam indexer/i })).toHaveAttribute("href", "/indexer"); }); }); diff --git a/frontend/__tests__/indexer-page.test.tsx b/frontend/__tests__/indexer-page.test.tsx new file mode 100644 index 0000000..bbafbee --- /dev/null +++ b/frontend/__tests__/indexer-page.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import IndexerPage from "../src/app/(routes)/indexer/page"; + +describe("Indexer page", () => { + it("embeds the Moonbeam indexer inside the app shell", () => { + render(); + + expect(screen.getByRole("heading", { name: /Moonbeam indexer/i })).toBeInTheDocument(); + const iframe = screen.getByTitle(/Moonbeam indexer/i); + expect(iframe).toHaveAttribute("src", "https://moonbeam.moonscan.io/"); + }); +}); diff --git a/frontend/__tests__/profile-page.test.tsx b/frontend/__tests__/profile-page.test.tsx index 5b102bf..ff394eb 100644 --- a/frontend/__tests__/profile-page.test.tsx +++ b/frontend/__tests__/profile-page.test.tsx @@ -36,7 +36,10 @@ describe("ProfilePage", () => { cubid_id: "cubid_me", display_name: "Maple Leaf", photo_url: "https://example.com/photo.png", + evm_address: "0x123", }, + walletAddress: null, + initialised: true, }); }); }); @@ -52,6 +55,7 @@ describe("ProfilePage", () => { expect(screen.getByText(/This name can be a nickname/i)).toBeInTheDocument(); expect(screen.getByText(/Maple Leaf/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /connect wallet/i })).toBeInTheDocument(); const previewHeading = screen.getByRole("heading", { name: /Preview for peers/i }); const previewAside = previewHeading.closest("aside"); diff --git a/frontend/__tests__/results-page.test.tsx b/frontend/__tests__/results-page.test.tsx index 4de4896..407c1fb 100644 --- a/frontend/__tests__/results-page.test.tsx +++ b/frontend/__tests__/results-page.test.tsx @@ -1,12 +1,45 @@ -import { render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; +import type { Session } from "@supabase/supabase-js"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ResultsPage from "../src/app/(routes)/results/page"; import { useScanStore } from "../src/lib/scanStore"; +import { useUserStore } from "../src/lib/store"; + +const { replaceMock } = vi.hoisted(() => ({ replaceMock: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: replaceMock }), +})); describe("ResultsPage", () => { beforeEach(() => { useScanStore.getState().reset(); + replaceMock.mockReset(); + act(() => { + useUserStore.getState().reset(); + }); + + const session = { access_token: "token", user: { id: "user-1" } } as unknown as Session; + act(() => { + useUserStore.setState({ + session, + user: { + user_id: "user-1", + cubid_id: "cubid_me", + display_name: "Maple", + evm_address: "0x123", + }, + walletAddress: null, + initialised: true, + }); + }); + }); + + afterEach(() => { + act(() => { + useUserStore.getState().reset(); + }); }); it("prompts for verification when no result is cached", () => { diff --git a/frontend/__tests__/scan-camera-page.test.tsx b/frontend/__tests__/scan-camera-page.test.tsx index 7c421fa..268451e 100644 --- a/frontend/__tests__/scan-camera-page.test.tsx +++ b/frontend/__tests__/scan-camera-page.test.tsx @@ -26,6 +26,7 @@ const { vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock, + replace: vi.fn(), }), })); @@ -72,8 +73,14 @@ describe("CameraPage", () => { useUserStore.setState({ session, - user: { user_id: session.user.id, cubid_id: "cubid_me" }, + user: { + user_id: session.user.id, + cubid_id: "cubid_me", + display_name: "Maple", + evm_address: "0xViewer", + }, walletAddress: null, + initialised: true, }); getUserMediaMock.mockResolvedValue({ diff --git a/frontend/__tests__/scan-qr-page.test.tsx b/frontend/__tests__/scan-qr-page.test.tsx index 9174a7b..57aa49d 100644 --- a/frontend/__tests__/scan-qr-page.test.tsx +++ b/frontend/__tests__/scan-qr-page.test.tsx @@ -16,6 +16,7 @@ const { pushMock, subscribeToHandshakeMock, randomUuidMock } = vi.hoisted(() => vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock, + replace: vi.fn(), }), })); @@ -48,7 +49,10 @@ describe("MyQrPage", () => { user_id: "user-1", cubid_id: "cubid_me", display_name: "Casey Rivers", + evm_address: "0x123", }, + walletAddress: null, + initialised: true, }); }); diff --git a/frontend/package.json b/frontend/package.json index a187749..348abfd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.80.0", + "jsqr": "^1.4.0", "qrcode.react": "^4.2.0", "next": "16.0.1", "react": "19.2.0", diff --git a/frontend/src/app/(routes)/circle/page.tsx b/frontend/src/app/(routes)/circle/page.tsx index ac0d2d3..a082485 100644 --- a/frontend/src/app/(routes)/circle/page.tsx +++ b/frontend/src/app/(routes)/circle/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ProfileResponse } from "@/lib/api"; import { getProfile } from "@/lib/api"; +import { useRequireCompletedOnboarding } from "@/lib/onboarding"; import { useUserStore } from "@/lib/store"; function formatFreshness(seconds: number): string { @@ -14,33 +15,38 @@ function formatFreshness(seconds: number): string { } export default function CirclePage() { - const profile = useUserStore((state) => state.user); + const { profile, ready } = useRequireCompletedOnboarding(); const walletAddress = useUserStore((state) => state.walletAddress ?? state.user?.evm_address ?? null); - const [query, setQuery] = useState(profile?.cubid_id ?? ""); - const [activeCubid, setActiveCubid] = useState(profile?.cubid_id ?? ""); const [data, setData] = useState(null); const [status, setStatus] = useState(null); const [error, setError] = useState(null); - const profileReady = useMemo(() => Boolean(profile?.cubid_id), [profile?.cubid_id]); - - useEffect(() => { - setQuery(profile?.cubid_id ?? ""); - setActiveCubid(profile?.cubid_id ?? ""); - }, [profile?.cubid_id]); - useEffect(() => { - if (!activeCubid) { + if (!ready) { + setData(null); + return; + } + const cubidId = profile?.cubid_id ?? null; + const issuer = walletAddress ?? profile?.evm_address ?? null; + if (!cubidId) { setData(null); + setStatus(null); + setError("Your profile is missing a Cubid ID."); + return; + } + if (!issuer) { + setData(null); + setStatus(null); + setError("Connect your wallet to view issued credentials."); return; } let cancelled = false; - async function fetchData() { + async function fetchData(currentCubid: string, currentIssuer: string) { setStatus("Loading attestations…"); setError(null); try { - const result = await getProfile(activeCubid, { issuer: walletAddress ?? undefined }); + const result = await getProfile(currentCubid, { issuer: currentIssuer }); if (!cancelled) { setData(result); setStatus(null); @@ -54,51 +60,55 @@ export default function CirclePage() { } } - fetchData(); + void fetchData(cubidId, issuer); return () => { cancelled = true; }; - }, [activeCubid, walletAddress]); + }, [profile?.cubid_id, profile?.evm_address, ready, walletAddress]); + + const groupedByCircle = useMemo(() => { + if (!data) { + return [] as Array<{ circle: string | null; items: ProfileResponse["outbound"] }>; + } + const groups = new Map(); + for (const attn of data.outbound) { + const key = attn.circle ?? null; + const list = groups.get(key) ?? []; + list.push(attn); + groups.set(key, list); + } + return Array.from(groups.entries()) + .map(([circle, items]) => ({ circle, items: items.sort((a, b) => a.freshnessSeconds - b.freshnessSeconds) })) + .sort((a, b) => { + const circleA = a.circle ?? ""; + const circleB = b.circle ?? ""; + return circleA.localeCompare(circleB); + }); + }, [data]); + + if (!ready) { + return ( +
+

Checking your circle…

+

We’re verifying your onboarding details.

+
+ ); + } return (

My Circle

- View the latest inbound attestations for a Cubid ID and track who you've vouched for using your connected wallet. + Explore every credential you've issued and see who's vouched for you.

-
- - {!profileReady ? ( -

Complete onboarding to view your circle.

- ) : null} -
- {status ?

{status}

: null} {error ?

{error}

: null} {data ? ( -
+

Inbound ({data.inbound.length})

Wallets who vouched for {data.cubidId}.

@@ -114,22 +124,37 @@ export default function CirclePage() { {data.inbound.length === 0 ?
  • No attestations yet.
  • : null}
    -
    -

    Outbound ({data.outbound.length})

    -

    - Cubid IDs you've vouched for as {walletAddress ?? "your wallet"}. -

    -
      - {data.outbound.map((attn) => ( -
    • -

      {attn.cubidId}

      -

      - Trust level {attn.trustLevel} · {attn.circle ?? "no circle"} · {formatFreshness(attn.freshnessSeconds)} -

      -
    • + +
      +
      +

      Outbound ({data.outbound.length})

      +

      + Credentials you've issued as {walletAddress ?? profile?.evm_address} grouped by circle. +

      +
      + {groupedByCircle.length === 0 ? ( +

      You haven't issued any credentials yet.

      + ) : null} +
      + {groupedByCircle.map((group) => ( +
      +
      +

      {group.circle ?? "No circle tag"}

      +

      {group.items.length} credential{group.items.length === 1 ? "" : "s"}

      +
      +
        + {group.items.map((attn) => ( +
      • +

        {attn.cubidId}

        +

        + Trust level {attn.trustLevel} · {formatFreshness(attn.freshnessSeconds)} · Issued {new Date(attn.issuedAt * 1000).toLocaleString()} +

        +
      • + ))} +
      +
      ))} - {data.outbound.length === 0 ?
    • No outbound vouches yet.
    • : null} -
    +
    ) : null} diff --git a/frontend/src/app/(routes)/indexer/page.tsx b/frontend/src/app/(routes)/indexer/page.tsx new file mode 100644 index 0000000..746acd1 --- /dev/null +++ b/frontend/src/app/(routes)/indexer/page.tsx @@ -0,0 +1,27 @@ +const INDEXER_URL = "https://moonbeam.moonscan.io/"; + +export default function IndexerPage() { + return ( +
    +
    + Moonbeam tooling +

    Moonbeam indexer

    +

    + Browse transaction details, contract events, and on-chain history without leaving Trust Me Bro. The + Moonbeam explorer loads below inside the app shell so you can jump straight back into handshakes when you are + done. +

    +
    + +
    +