From 90b52dfc80efc5ebf53b8560b05d5b13fd268b4c Mon Sep 17 00:00:00 2001 From: KazanderDad <98373366+KazanderDad@users.noreply.github.com> Date: Sun, 9 Nov 2025 12:37:17 -0500 Subject: [PATCH 1/2] feat(frontend): improve qr share and handshake flow --- frontend/__tests__/app-header.test.tsx | 2 +- frontend/__tests__/profile-page.test.tsx | 82 ++++++++ frontend/__tests__/scan-camera-page.test.tsx | 61 +++++- frontend/__tests__/scan-qr-page.test.tsx | 92 +++++++++ frontend/package.json | 1 + frontend/src/app/(routes)/new-user/page.tsx | 10 +- frontend/src/app/(routes)/profile/page.tsx | 121 ++++++++--- .../src/app/(routes)/scan/camera/page.tsx | 189 ++++++++++-------- frontend/src/app/(routes)/scan/my-qr/page.tsx | 57 +++++- frontend/src/app/layout.tsx | 2 +- frontend/src/app/page.tsx | 4 +- frontend/src/components/AppFooter.tsx | 2 +- frontend/src/components/AppHeader.tsx | 8 +- frontend/src/components/QRDisplay.tsx | 39 +++- frontend/src/components/QRScanner.tsx | 105 ++++++++-- frontend/src/lib/handshake.ts | 73 +++++++ pnpm-lock.yaml | 15 ++ 17 files changed, 710 insertions(+), 153 deletions(-) create mode 100644 frontend/__tests__/profile-page.test.tsx create mode 100644 frontend/__tests__/scan-qr-page.test.tsx create mode 100644 frontend/src/lib/handshake.ts diff --git a/frontend/__tests__/app-header.test.tsx b/frontend/__tests__/app-header.test.tsx index e3c70b2..072e0d4 100644 --- a/frontend/__tests__/app-header.test.tsx +++ b/frontend/__tests__/app-header.test.tsx @@ -38,7 +38,7 @@ describe("AppHeader", () => { render(); - expect(screen.getByRole("link", { name: /Peer Mapper/i })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /Trust Me Bro/i })).toHaveAttribute("href", "/"); 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"); diff --git a/frontend/__tests__/profile-page.test.tsx b/frontend/__tests__/profile-page.test.tsx new file mode 100644 index 0000000..5b102bf --- /dev/null +++ b/frontend/__tests__/profile-page.test.tsx @@ -0,0 +1,82 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ProfilePage from "../src/app/(routes)/profile/page"; +import { useUserStore } from "../src/lib/store"; + +const { replaceMock } = vi.hoisted(() => ({ + replaceMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: replaceMock, + }), +})); + +describe("ProfilePage", () => { + beforeEach(() => { + replaceMock.mockReset(); + act(() => { + useUserStore.getState().reset(); + }); + + const session = { + access_token: "token", + user: { id: "user-1", email: "user@example.com" }, + } as unknown as Session; + + act(() => { + useUserStore.setState({ + session, + user: { + user_id: session.user.id, + cubid_id: "cubid_me", + display_name: "Maple Leaf", + photo_url: "https://example.com/photo.png", + }, + }); + }); + }); + + afterEach(() => { + act(() => { + useUserStore.getState().reset(); + }); + }); + + it("shows an on-page preview of the profile", () => { + render(); + + expect(screen.getByText(/This name can be a nickname/i)).toBeInTheDocument(); + expect(screen.getByText(/Maple Leaf/)).toBeInTheDocument(); + + const previewHeading = screen.getByRole("heading", { name: /Preview for peers/i }); + const previewAside = previewHeading.closest("aside"); + expect(previewAside).not.toBeNull(); + if (previewAside) { + expect(within(previewAside).getByText(/Cubid ID: cubid_me/)).toBeInTheDocument(); + } + + const previewImage = screen.getByAltText(/Profile photo preview/i) as HTMLImageElement; + expect(previewImage.src).toContain("https://example.com/photo.png"); + }); + + it("surfaces a warning when the photo URL cannot load", async () => { + const user = userEvent.setup(); + + render(); + + const photoInput = screen.getByLabelText(/Photo URL/i); + await user.clear(photoInput); + await user.type(photoInput, "https://example.com/broken.png"); + + const previewImage = screen.getByAltText(/Profile photo preview/i); + fireEvent.error(previewImage); + + expect(screen.getByText(/We couldn’t load this image/i)).toBeInTheDocument(); + expect(screen.getByText("!")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/scan-camera-page.test.tsx b/frontend/__tests__/scan-camera-page.test.tsx index 546c1d7..af265d2 100644 --- a/frontend/__tests__/scan-camera-page.test.tsx +++ b/frontend/__tests__/scan-camera-page.test.tsx @@ -1,17 +1,26 @@ import type { Session } from "@supabase/supabase-js"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import CameraPage from "../src/app/(routes)/scan/camera/page"; import { useScanStore } from "../src/lib/scanStore"; import { useUserStore } from "../src/lib/store"; -const { pushMock, requestQrChallengeMock, verifyQrChallengeMock, ensureWalletMock } = vi.hoisted(() => ({ +const { + pushMock, + requestQrChallengeMock, + verifyQrChallengeMock, + ensureWalletMock, + getUserMediaMock, + notifyHandshakeCompleteMock, +} = vi.hoisted(() => ({ pushMock: vi.fn(), requestQrChallengeMock: vi.fn(), verifyQrChallengeMock: vi.fn(), ensureWalletMock: vi.fn(), + getUserMediaMock: vi.fn(), + notifyHandshakeCompleteMock: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -29,12 +38,28 @@ vi.mock("../src/lib/wallet", () => ({ ensureWallet: ensureWalletMock, })); +vi.mock("../src/lib/handshake", () => ({ + notifyHandshakeComplete: notifyHandshakeCompleteMock, +})); + +const originalPlay = HTMLMediaElement.prototype.play; + +beforeAll(() => { + HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); +}); + +afterAll(() => { + HTMLMediaElement.prototype.play = originalPlay; +}); + describe("CameraPage", () => { beforeEach(() => { pushMock.mockReset(); requestQrChallengeMock.mockReset(); verifyQrChallengeMock.mockReset(); ensureWalletMock.mockReset(); + getUserMediaMock.mockReset(); + notifyHandshakeCompleteMock.mockReset(); act(() => { useUserStore.getState().reset(); useScanStore.getState().reset(); @@ -51,6 +76,15 @@ describe("CameraPage", () => { walletAddress: null, }); + getUserMediaMock.mockResolvedValue({ + getTracks: () => [{ stop: vi.fn() }], + } as unknown as MediaStream); + + Object.defineProperty(window.navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: getUserMediaMock }, + }); + const ethereumRequest = vi.fn(); (window as typeof window & { ethereum?: { request: ReturnType } }).ethereum = { request: ethereumRequest, @@ -59,6 +93,7 @@ describe("CameraPage", () => { afterEach(() => { delete (window as typeof window & { ethereum?: unknown }).ethereum; + delete (window.navigator as Navigator & { mediaDevices?: MediaDevices }).mediaDevices; }); it("completes the QR verification happy path", async () => { @@ -86,8 +121,14 @@ describe("CameraPage", () => { ], }); + notifyHandshakeCompleteMock.mockResolvedValueOnce(undefined); + render(); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + const devSummary = await screen.findByText(/for devs only/i); + await user.click(devSummary); + const payloadTextarea = screen.getByPlaceholderText(/Paste JSON like/); const targetAddress = "0x000000000000000000000000000000000000dEaD"; fireEvent.change(payloadTextarea, { @@ -138,6 +179,18 @@ describe("CameraPage", () => { ), ); + await waitFor(() => + expect(notifyHandshakeCompleteMock).toHaveBeenCalledWith({ + challengeId: "challenge-1", + expiresAt: 1700001200, + overlaps: [ + { issuer: "0xIssuer", trustLevel: 4, circle: null, freshnessSeconds: 42 }, + ], + targetCubid: "cubid_peer", + viewerCubid: "cubid_me", + }), + ); + await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/results")); const result = useScanStore.getState().lastResult; @@ -150,6 +203,10 @@ describe("CameraPage", () => { render(); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + const devSummary = await screen.findByText(/for devs only/i); + await user.click(devSummary); + const payloadTextarea = screen.getByPlaceholderText(/Paste JSON like/); fireEvent.change(payloadTextarea, { target: { value: '{"address":"0xPeer"}' }, diff --git a/frontend/__tests__/scan-qr-page.test.tsx b/frontend/__tests__/scan-qr-page.test.tsx new file mode 100644 index 0000000..05be86d --- /dev/null +++ b/frontend/__tests__/scan-qr-page.test.tsx @@ -0,0 +1,92 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import MyQrPage from "../src/app/(routes)/scan/my-qr/page"; +import type { HandshakeCompletion } from "../src/lib/handshake"; +import { useScanStore } from "../src/lib/scanStore"; +import { useUserStore } from "../src/lib/store"; + +const { pushMock, subscribeToHandshakeMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + subscribeToHandshakeMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +vi.mock("../src/lib/handshake", () => ({ + subscribeToHandshake: subscribeToHandshakeMock, +})); + +describe("MyQrPage", () => { + beforeEach(() => { + pushMock.mockReset(); + subscribeToHandshakeMock.mockReset(); + act(() => { + useUserStore.getState().reset(); + useScanStore.getState().reset(); + }); + + const session = { access_token: "token", user: { id: "user-1" } } as unknown as Session; + useUserStore.setState({ + session, + user: { + user_id: "user-1", + cubid_id: "cubid_me", + display_name: "Casey Rivers", + }, + }); + }); + + afterEach(() => { + subscribeToHandshakeMock.mockReset(); + }); + + it("renders the QR code and profile name", async () => { + subscribeToHandshakeMock.mockReturnValue(() => undefined); + + render(); + + await waitFor(() => expect(subscribeToHandshakeMock).toHaveBeenCalledWith("cubid_me", expect.any(Function))); + + expect(screen.getByRole("img", { name: /scan this within ninety seconds/i })).toBeInTheDocument(); + expect(screen.getByText("Casey Rivers")).toBeInTheDocument(); + expect(screen.getByText(/Cubid ID: cubid_me/i)).toBeInTheDocument(); + }); + + it("redirects to results when a handshake completes", async () => { + let handler: ((payload: HandshakeCompletion) => void) | null = null; + subscribeToHandshakeMock.mockImplementation((_cubid, callback) => { + handler = callback; + return vi.fn(); + }); + + render(); + + await waitFor(() => expect(handler).toBeInstanceOf(Function)); + + const payload: HandshakeCompletion = { + challengeId: "challenge-1", + expiresAt: 1700001200, + overlaps: [], + targetCubid: "cubid_me", + viewerCubid: "cubid_peer", + }; + + await act(async () => { + handler?.(payload); + }); + + await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/results")); + + expect(screen.getByText(/Handshake completed/i)).toBeInTheDocument(); + const result = useScanStore.getState().lastResult; + expect(result?.challengeId).toBe("challenge-1"); + expect(result?.viewerCubid).toBe("cubid_peer"); + expect(result?.targetCubid).toBe("cubid_me"); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 95d56d6..a187749 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", + "qrcode.react": "^4.2.0", "next": "16.0.1", "react": "19.2.0", "react-dom": "19.2.0", diff --git a/frontend/src/app/(routes)/new-user/page.tsx b/frontend/src/app/(routes)/new-user/page.tsx index 0af7f2c..a1a3b9f 100644 --- a/frontend/src/app/(routes)/new-user/page.tsx +++ b/frontend/src/app/(routes)/new-user/page.tsx @@ -29,10 +29,10 @@ export default function NewUserPage() { router.replace("/(routes)/signin"); return; } - if (profile?.cubid_id && profile?.display_name && profile?.evm_address) { - router.replace("/(routes)/circle"); + if (profile?.cubid_id) { + router.replace("/(routes)/profile"); } - }, [profile, router, session]); + }, [profile?.cubid_id, router, session]); useEffect(() => { setForm({ @@ -105,7 +105,7 @@ export default function NewUserPage() { return (
-

Welcome to Peer Mapper

+

Welcome to Trust Me Bro

Confirm your Cubid identity, pick a display name, and connect your Nova/EVM wallet to start vouching and scanning.

@@ -119,7 +119,7 @@ export default function NewUserPage() { setForm((prev) => ({ ...prev, displayName: event.target.value }))} - placeholder="Casey Mapper" + placeholder="Casey Rivers" value={form.displayName} /> diff --git a/frontend/src/app/(routes)/profile/page.tsx b/frontend/src/app/(routes)/profile/page.tsx index bf60276..74c4b4e 100644 --- a/frontend/src/app/(routes)/profile/page.tsx +++ b/frontend/src/app/(routes)/profile/page.tsx @@ -1,7 +1,8 @@ "use client"; + import { useRouter } from "next/navigation"; -import { type FormEvent, useEffect, useState } from "react"; +import { type FormEvent, useEffect, useMemo, useState } from "react"; import { upsertMyProfile } from "../../../lib/profile"; import { useUserStore } from "../../../lib/store"; @@ -12,10 +13,23 @@ export default function ProfilePage() { const profile = useUserStore((state) => state.user); const setUser = useUserStore((state) => state.setUser); - const [displayName, setDisplayName] = useState(""); - const [photoUrl, setPhotoUrl] = useState(""); + const [displayName, setDisplayName] = useState(profile?.display_name ?? ""); + const [photoUrl, setPhotoUrl] = useState(profile?.photo_url ?? ""); const [status, setStatus] = useState(null); const [error, setError] = useState(null); + const [imageError, setImageError] = useState(false); + + const photoPreviewUrl = useMemo(() => photoUrl || profile?.photo_url || "", [photoUrl, profile?.photo_url]); + const previewName = displayName || profile?.display_name || "Your chosen name"; + + useEffect(() => { + setDisplayName(profile?.display_name ?? ""); + setPhotoUrl(profile?.photo_url ?? ""); + }, [profile?.display_name, profile?.photo_url]); + + useEffect(() => { + setImageError(false); + }, [photoPreviewUrl]); useEffect(() => { if (!session) { @@ -51,7 +65,9 @@ export default function ProfilePage() {

My profile

-

Review your Cubid ID and refresh your display information.

+

+ This name can be a nickname. Your name, picture, and Cubid ID are what you share with anyone you invite to connect. +

@@ -59,36 +75,75 @@ export default function ProfilePage() {

Wallet: {profile?.evm_address ?? "—"}

-
- - - -
+
+
+ + + + {status ?

{status}

: null} + {error ?

{error}

: null} +
- {status ?

{status}

: null} - {error ?

{error}

: null} + +
); } diff --git a/frontend/src/app/(routes)/scan/camera/page.tsx b/frontend/src/app/(routes)/scan/camera/page.tsx index 3b5db37..24fa047 100644 --- a/frontend/src/app/(routes)/scan/camera/page.tsx +++ b/frontend/src/app/(routes)/scan/camera/page.tsx @@ -5,6 +5,7 @@ import { type FormEvent, useState } from "react"; import QRScanner from "@/components/QRScanner"; import { requestQrChallenge, verifyQrChallenge } from "@/lib/api"; +import { notifyHandshakeComplete } from "@/lib/handshake"; import { useScanStore } from "@/lib/scanStore"; import { useUserStore } from "@/lib/store"; import { ensureWallet } from "@/lib/wallet"; @@ -156,15 +157,26 @@ export default function CameraPage() { session.access_token, ); - setResult({ + const completion = { targetCubid: parsed.cubidId, viewerCubid: profile.cubid_id, challengeId: result.challengeId, expiresAt: result.expiresAt, overlaps: result.overlaps, - verifiedAt: Date.now(), - }); - setStatus("Overlap ready"); + } as const; + + setResult({ ...completion, verifiedAt: Date.now() }); + setStatus("Sharing overlaps with your peer…"); + + try { + await notifyHandshakeComplete(completion); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to notify peer of handshake"; + setError(message); + setStatus(null); + return; + } + router.push("/results"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to verify challenge"; @@ -184,87 +196,108 @@ export default function CameraPage() {

-
-
+
+
-