From 006a27acb558d8c4a8467a7c04be0959be90a058 Mon Sep 17 00:00:00 2001 From: KazanderDad <98373366+KazanderDad@users.noreply.github.com> Date: Sun, 9 Nov 2025 22:24:17 -0500 Subject: [PATCH 1/2] feat: redesign new user onboarding flow --- frontend/__mocks__/jsqr.ts | 3 + frontend/__tests__/new-user-page.test.tsx | 162 ++++++++++ frontend/src/app/new-user/page.tsx | 376 +++++++++++++++++----- frontend/vitest.config.ts | 5 +- frontend/vitest.setup.ts | 6 + 5 files changed, 476 insertions(+), 76 deletions(-) create mode 100644 frontend/__mocks__/jsqr.ts create mode 100644 frontend/__tests__/new-user-page.test.tsx diff --git a/frontend/__mocks__/jsqr.ts b/frontend/__mocks__/jsqr.ts new file mode 100644 index 0000000..bfd457f --- /dev/null +++ b/frontend/__mocks__/jsqr.ts @@ -0,0 +1,3 @@ +export default function mockJsqr() { + return null; +} diff --git a/frontend/__tests__/new-user-page.test.tsx b/frontend/__tests__/new-user-page.test.tsx new file mode 100644 index 0000000..c09ddac --- /dev/null +++ b/frontend/__tests__/new-user-page.test.tsx @@ -0,0 +1,162 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import NewUserPage from "../src/app/new-user/page"; +import { useUserStore } from "../src/lib/store"; + +const { + pushMock, + requestCubidIdMock, + ensureWalletMock, + upsertMyProfileMock, + uploadMock, + getPublicUrlMock, + createObjectURLMock, + revokeObjectURLMock, +} = vi.hoisted(() => ({ + pushMock: vi.fn(), + requestCubidIdMock: vi.fn<[], Promise>(), + ensureWalletMock: vi.fn<[], Promise>(), + upsertMyProfileMock: vi.fn(), + uploadMock: vi.fn(), + getPublicUrlMock: vi.fn(), + createObjectURLMock: vi.fn(() => "blob:preview"), + revokeObjectURLMock: vi.fn(), +})); +let originalFetch: typeof globalThis.fetch; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +vi.mock("../src/lib/cubid", async () => { + const actual = await vi.importActual("../src/lib/cubid"); + return { + ...actual, + requestCubidId: requestCubidIdMock, + }; +}); + +vi.mock("../src/lib/onboarding", () => ({ + useRestrictToIncompleteOnboarding: () => ({ + ready: true, + session: { + user: { id: "user-1", email: "user@example.com" }, + } as unknown as Session, + profile: { user_id: "user-1" }, + }), +})); + +vi.mock("../src/lib/profile", () => ({ + upsertMyProfile: (...args: unknown[]) => upsertMyProfileMock(...args), +})); + +vi.mock("../src/lib/wallet", () => ({ + ensureWallet: (...args: unknown[]) => ensureWalletMock(...args), +})); + +vi.mock("../src/lib/supabaseClient", () => ({ + getSupabaseClient: () => ({ + storage: { + from: () => ({ + upload: uploadMock, + getPublicUrl: getPublicUrlMock, + }), + }, + }), +})); + +describe("NewUserPage", () => { + beforeEach(() => { + pushMock.mockReset(); + requestCubidIdMock.mockResolvedValue("cubid_testabcd"); + ensureWalletMock.mockResolvedValue("0xwallet"); + upsertMyProfileMock.mockImplementation(async (payload) => ({ + user_id: "user-1", + ...payload, + })); + uploadMock.mockResolvedValue({ error: null }); + getPublicUrlMock.mockImplementation((path: string) => ({ + data: { publicUrl: `https://supabase.test/${path}` }, + })); + + originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch; + + createObjectURLMock.mockReset(); + createObjectURLMock.mockReturnValue("blob:preview"); + revokeObjectURLMock.mockReset(); + const globalUrl = globalThis.URL as unknown as Record; + globalUrl.createObjectURL = createObjectURLMock; + globalUrl.revokeObjectURL = revokeObjectURLMock; + + act(() => { + useUserStore.getState().reset(); + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + const globalUrl = globalThis.URL as unknown as Record; + globalUrl.createObjectURL = createObjectURLMock; + globalUrl.revokeObjectURL = revokeObjectURLMock; + upsertMyProfileMock.mockReset(); + requestCubidIdMock.mockReset(); + ensureWalletMock.mockReset(); + uploadMock.mockReset(); + getPublicUrlMock.mockReset(); + pushMock.mockReset(); + act(() => { + useUserStore.getState().reset(); + }); + }); + + it("guides the user through the onboarding steps", async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => expect(requestCubidIdMock).toHaveBeenCalled()); + + const nameInput = screen.getByPlaceholderText("Casey Rivers"); + await user.type(nameInput, "Maple Leaf"); + + await user.click(screen.getByRole("button", { name: /next/i })); + + await screen.findByText(/share a photo/i); + + const fileInput = screen.getByLabelText(/upload a photo/i) as HTMLInputElement; + const file = new File(["avatar"], "avatar.png", { type: "image/png" }); + await user.upload(fileInput, file); + + await user.click(screen.getByRole("button", { name: /next/i })); + + await waitFor(() => expect(uploadMock).toHaveBeenCalled()); + expect(uploadMock.mock.calls[0][0]).toMatch(/cubid_testabcd/); + + await screen.findByText(/connect your wallet/i); + + await user.click(screen.getByRole("button", { name: /connect wallet/i })); + + await waitFor(() => expect(ensureWalletMock).toHaveBeenCalled()); + await waitFor(() => expect(upsertMyProfileMock).toHaveBeenCalledWith({ evm_address: "0xwallet" })); + + const cubidInput = await screen.findByDisplayValue("cubid_testabcd"); + expect(cubidInput).toHaveAttribute("readonly"); + + await user.click(screen.getByRole("button", { name: /finish/i })); + + await waitFor(() => + expect(upsertMyProfileMock).toHaveBeenCalledWith({ + cubid_id: "cubid_testabcd", + display_name: "Maple Leaf", + photo_url: expect.stringContaining("https://supabase.test/"), + }), + ); + await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/circle")); + }); +}); diff --git a/frontend/src/app/new-user/page.tsx b/frontend/src/app/new-user/page.tsx index 8aed83b..5008a65 100644 --- a/frontend/src/app/new-user/page.tsx +++ b/frontend/src/app/new-user/page.tsx @@ -1,12 +1,13 @@ "use client"; import { useRouter } from "next/navigation"; -import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { type ChangeEvent, type FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { isValidCubidId, requestCubidId } from "../../lib/cubid"; import { useRestrictToIncompleteOnboarding } from "../../lib/onboarding"; import { upsertMyProfile } from "../../lib/profile"; import { useUserStore } from "../../lib/store"; +import { getSupabaseClient } from "../../lib/supabaseClient"; import { ensureWallet } from "../../lib/wallet"; function createRandomCubidId(): string { @@ -17,11 +18,14 @@ function createRandomCubidId(): string { return `cubid_${Math.random().toString(36).slice(2, 34)}`; } +type OnboardingStep = 0 | 1 | 2; + export default function NewUserPage() { const router = useRouter(); const { session, profile, ready } = useRestrictToIncompleteOnboarding(); const setUser = useUserStore((state) => state.setUser); const setWalletAddress = useUserStore((state) => state.setWalletAddress); + const walletAddress = useUserStore((state) => state.walletAddress); const initialCubidId = useMemo(() => profile?.cubid_id ?? createRandomCubidId(), [profile?.cubid_id]); const [form, setForm] = useState({ @@ -29,9 +33,27 @@ export default function NewUserPage() { photoUrl: profile?.photo_url ?? "", cubidId: initialCubidId, }); + const [step, setStep] = useState(0); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); + const [uploadingPhoto, setUploadingPhoto] = useState(false); + const [photoFile, setPhotoFile] = useState(null); + const [photoLink, setPhotoLink] = useState(""); + const [photoPreview, setPhotoPreview] = useState(profile?.photo_url ?? null); + const hasRequestedCubidId = useRef(false); + const previewObjectUrl = useRef(null); + + function updatePhotoPreview(value: string | null, isObjectUrl: boolean) { + if (previewObjectUrl.current) { + URL.revokeObjectURL(previewObjectUrl.current); + previewObjectUrl.current = null; + } + if (isObjectUrl && value) { + previewObjectUrl.current = value; + } + setPhotoPreview(value); + } useEffect(() => { setForm((prev) => ({ @@ -39,8 +61,40 @@ export default function NewUserPage() { photoUrl: profile?.photo_url ?? "", cubidId: profile?.cubid_id ?? prev.cubidId ?? initialCubidId, })); + if (profile?.photo_url) { + updatePhotoPreview(profile.photo_url, false); + } }, [initialCubidId, profile?.cubid_id, profile?.display_name, profile?.photo_url]); + useEffect(() => { + return () => { + if (previewObjectUrl.current) { + URL.revokeObjectURL(previewObjectUrl.current); + previewObjectUrl.current = null; + } + }; + }, []); + + useEffect(() => { + if (!session?.user?.email) { + return; + } + if (profile?.cubid_id || hasRequestedCubidId.current) { + return; + } + + hasRequestedCubidId.current = true; + requestCubidId(session.user.email) + .then((cubid) => { + setForm((prev) => ({ ...prev, cubidId: cubid })); + setStatus("Cubid ID prepared"); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : "Failed to generate Cubid ID"; + setError(message); + }); + }, [profile?.cubid_id, session?.user?.email]); + if (!ready) { return (
@@ -50,23 +104,6 @@ export default function NewUserPage() { ); } - async function handleGenerateCubid() { - if (!session?.user?.email) { - setError("Session missing email address"); - return; - } - setError(null); - try { - const cubid = await requestCubidId(session.user.email); - setForm((prev) => ({ ...prev, cubidId: cubid })); - setStatus("Cubid ID generated"); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to generate Cubid ID"; - setError(message); - setStatus(null); - } - } - async function handleConnectWallet() { setError(null); setStatus("Requesting wallet access…"); @@ -76,6 +113,7 @@ export default function NewUserPage() { setUser(updated); setWalletAddress(address); setStatus("Wallet linked"); + setStep(2); } catch (err) { const message = err instanceof Error ? err.message : "Wallet connection failed"; setError(message); @@ -113,90 +151,278 @@ export default function NewUserPage() { } } + function handleNameNext(event: FormEvent) { + event.preventDefault(); + if (!form.displayName.trim()) { + setError("Please share your name to continue"); + return; + } + setError(null); + setStatus(null); + setStep(1); + } + + function handleFileSelection(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + setPhotoFile(file); + updatePhotoPreview(URL.createObjectURL(file), true); + setPhotoLink(""); + } + + async function uploadPhotoFromLinkOrFile() { + if (!session?.user?.id) { + throw new Error("Missing session information"); + } + const supabase = getSupabaseClient(); + let fileToUpload: File; + + if (photoFile) { + fileToUpload = photoFile; + } else if (photoLink) { + const response = await fetch(photoLink); + if (!response.ok) { + throw new Error("We couldn't fetch that image link"); + } + const contentType = response.headers.get("content-type") ?? "image/jpeg"; + const extension = inferExtensionFromSource(photoLink, contentType); + const blob = await response.blob(); + fileToUpload = new File([blob], `linked.${extension}`, { type: contentType }); + updatePhotoPreview(URL.createObjectURL(blob), true); + } else if (form.photoUrl) { + // Existing profile photo already stored in Supabase. + setStatus("Photo ready"); + return; + } else { + throw new Error("Please add a photo before continuing"); + } + + const extension = fileToUpload.name.split(".").pop() ?? "jpg"; + const sanitizedCubid = form.cubidId.replace(/[^a-z0-9_]/gi, ""); + const storagePath = `${sanitizedCubid || session.user.id}-${Date.now()}.${extension}`; + const { error: uploadError } = await supabase.storage + .from("profile-pictures") + .upload(storagePath, fileToUpload, { + upsert: true, + contentType: fileToUpload.type, + }); + + if (uploadError) { + throw uploadError; + } + + const { + data: { publicUrl }, + } = supabase.storage.from("profile-pictures").getPublicUrl(storagePath); + + setForm((prev) => ({ ...prev, photoUrl: publicUrl })); + setStatus("Photo uploaded"); + } + + async function handlePhotoNext(event: FormEvent) { + event.preventDefault(); + if (!session) { + return; + } + setError(null); + setStatus("Uploading photo…"); + setUploadingPhoto(true); + try { + await uploadPhotoFromLinkOrFile(); + setStep(2); + } catch (err) { + const message = err instanceof Error ? err.message : "We couldn't save your photo"; + setError(message); + setStatus(null); + } finally { + setUploadingPhoto(false); + } + } + + async function handleComplete(event: FormEvent) { + await handleSubmit(event); + } + return (

Welcome to Trust Me Bro

- Confirm your Cubid identity, pick a display name, and connect your Nova/EVM wallet to start vouching and scanning. + We'll gather a few details to build your profile: your name, a photo, and your wallet.

-
-
- Profile basics -
- -
- Cubid identity -
-
-
- -
- Wallet -

- Link the EVM account you'll use to sign vouches. We store the address with your profile for reuse. -

- -
- - - + + ) : null} + + {step === 2 ? ( +
+
+

Connect your wallet

+

+ Link the wallet you'll use for vouching. Once connected, we'll confirm your Cubid ID. +

+
+ + {walletAddress ? ( +
+
+
Wallet address
+
{walletAddress}
+
+
+
Cubid ID
+
+ +

+ Generated automatically from your email. Keep this handy for support. +

+
+
+
+ ) : null} +
+
+
+ + +
+
+ ) : null} {status ?

{status}

: null} {error ?

{error}

: null}
); } + +function inferExtensionFromSource(source: string, contentType: string): string { + const urlExtension = source.split("?")[0]?.split(".").pop(); + if (urlExtension && /^[a-z0-9]+$/i.test(urlExtension)) { + return urlExtension; + } + const mimeExtension = contentType.split("/")[1]; + if (mimeExtension) { + return mimeExtension; + } + return "jpg"; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 5780859..36f6f3b 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -4,7 +4,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { - alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }], + alias: [ + { find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }, + { find: "jsqr", replacement: fileURLToPath(new URL("./__mocks__/jsqr.ts", import.meta.url)) }, + ], }, test: { environment: "jsdom", diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index f149f27..7dd8162 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -1 +1,7 @@ import "@testing-library/jest-dom/vitest"; + +import { vi } from "vitest"; + +vi.mock("jsqr", () => ({ + default: vi.fn(), +})); From 3d2e2e935bd9729ff5795917d0952b4a5c4df447 Mon Sep 17 00:00:00 2001 From: KazanderDad <98373366+KazanderDad@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:38:52 -0500 Subject: [PATCH 2/2] fix: harden new user onboarding flow --- frontend/__tests__/new-user-page.test.tsx | 16 +++++- frontend/src/app/new-user/page.tsx | 62 ++++++++++++++++------- frontend/vitest.setup.ts | 6 --- 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/frontend/__tests__/new-user-page.test.tsx b/frontend/__tests__/new-user-page.test.tsx index c09ddac..b62708f 100644 --- a/frontend/__tests__/new-user-page.test.tsx +++ b/frontend/__tests__/new-user-page.test.tsx @@ -26,6 +26,8 @@ const { revokeObjectURLMock: vi.fn(), })); let originalFetch: typeof globalThis.fetch; +let originalCreateObjectURL: typeof URL.createObjectURL | undefined; +let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined; vi.mock("next/navigation", () => ({ useRouter: () => ({ @@ -91,6 +93,8 @@ describe("NewUserPage", () => { createObjectURLMock.mockReturnValue("blob:preview"); revokeObjectURLMock.mockReset(); const globalUrl = globalThis.URL as unknown as Record; + originalCreateObjectURL = globalUrl.createObjectURL as typeof URL.createObjectURL | undefined; + originalRevokeObjectURL = globalUrl.revokeObjectURL as typeof URL.revokeObjectURL | undefined; globalUrl.createObjectURL = createObjectURLMock; globalUrl.revokeObjectURL = revokeObjectURLMock; @@ -102,8 +106,16 @@ describe("NewUserPage", () => { afterEach(() => { globalThis.fetch = originalFetch; const globalUrl = globalThis.URL as unknown as Record; - globalUrl.createObjectURL = createObjectURLMock; - globalUrl.revokeObjectURL = revokeObjectURLMock; + if (originalCreateObjectURL) { + globalUrl.createObjectURL = originalCreateObjectURL; + } else { + delete globalUrl.createObjectURL; + } + if (originalRevokeObjectURL) { + globalUrl.revokeObjectURL = originalRevokeObjectURL; + } else { + delete globalUrl.revokeObjectURL; + } upsertMyProfileMock.mockReset(); requestCubidIdMock.mockReset(); ensureWalletMock.mockReset(); diff --git a/frontend/src/app/new-user/page.tsx b/frontend/src/app/new-user/page.tsx index 5008a65..91c1dee 100644 --- a/frontend/src/app/new-user/page.tsx +++ b/frontend/src/app/new-user/page.tsx @@ -42,8 +42,13 @@ export default function NewUserPage() { const [photoLink, setPhotoLink] = useState(""); const [photoPreview, setPhotoPreview] = useState(profile?.photo_url ?? null); const hasRequestedCubidId = useRef(false); + const latestProfileRef = useRef(profile); const previewObjectUrl = useRef(null); + useEffect(() => { + latestProfileRef.current = profile; + }, [profile]); + function updatePhotoPreview(value: string | null, isObjectUrl: boolean) { if (previewObjectUrl.current) { URL.revokeObjectURL(previewObjectUrl.current); @@ -76,7 +81,7 @@ export default function NewUserPage() { }, []); useEffect(() => { - if (!session?.user?.email) { + if (!ready || !session?.user?.email) { return; } if (profile?.cubid_id || hasRequestedCubidId.current) { @@ -86,6 +91,9 @@ export default function NewUserPage() { hasRequestedCubidId.current = true; requestCubidId(session.user.email) .then((cubid) => { + if (latestProfileRef.current?.cubid_id) { + return; + } setForm((prev) => ({ ...prev, cubidId: cubid })); setStatus("Cubid ID prepared"); }) @@ -93,7 +101,7 @@ export default function NewUserPage() { const message = err instanceof Error ? err.message : "Failed to generate Cubid ID"; setError(message); }); - }, [profile?.cubid_id, session?.user?.email]); + }, [profile?.cubid_id, ready, session?.user?.email]); if (!ready) { return ( @@ -113,7 +121,6 @@ export default function NewUserPage() { setUser(updated); setWalletAddress(address); setStatus("Wallet linked"); - setStep(2); } catch (err) { const message = err instanceof Error ? err.message : "Wallet connection failed"; setError(message); @@ -184,13 +191,12 @@ export default function NewUserPage() { } else if (photoLink) { const response = await fetch(photoLink); if (!response.ok) { - throw new Error("We couldn't fetch that image link"); + throw new Error("We couldn't fetch that image link"); } const contentType = response.headers.get("content-type") ?? "image/jpeg"; const extension = inferExtensionFromSource(photoLink, contentType); const blob = await response.blob(); fileToUpload = new File([blob], `linked.${extension}`, { type: contentType }); - updatePhotoPreview(URL.createObjectURL(blob), true); } else if (form.photoUrl) { // Existing profile photo already stored in Supabase. setStatus("Photo ready"); @@ -218,6 +224,7 @@ export default function NewUserPage() { } = supabase.storage.from("profile-pictures").getPublicUrl(storagePath); setForm((prev) => ({ ...prev, photoUrl: publicUrl })); + updatePhotoPreview(publicUrl, false); setStatus("Photo uploaded"); } @@ -233,7 +240,7 @@ export default function NewUserPage() { await uploadPhotoFromLinkOrFile(); setStep(2); } catch (err) { - const message = err instanceof Error ? err.message : "We couldn't save your photo"; + const message = err instanceof Error ? err.message : "We couldn't save your photo"; setError(message); setStatus(null); } finally { @@ -250,22 +257,39 @@ export default function NewUserPage() {

Welcome to Trust Me Bro

- We'll gather a few details to build your profile: your name, a photo, and your wallet. + We'll gather a few details to build your profile: your name, a photo, and your wallet.

-
- Name - - 1 ? "text-blue-600" : ""}>Photo - - Wallet -
+ {step === 0 ? (