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..b62708f --- /dev/null +++ b/frontend/__tests__/new-user-page.test.tsx @@ -0,0 +1,174 @@ +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; +let originalCreateObjectURL: typeof URL.createObjectURL | undefined; +let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined; + +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; + originalCreateObjectURL = globalUrl.createObjectURL as typeof URL.createObjectURL | undefined; + originalRevokeObjectURL = globalUrl.revokeObjectURL as typeof URL.revokeObjectURL | undefined; + globalUrl.createObjectURL = createObjectURLMock; + globalUrl.revokeObjectURL = revokeObjectURLMock; + + act(() => { + useUserStore.getState().reset(); + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + const globalUrl = globalThis.URL as unknown as Record; + if (originalCreateObjectURL) { + globalUrl.createObjectURL = originalCreateObjectURL; + } else { + delete globalUrl.createObjectURL; + } + if (originalRevokeObjectURL) { + globalUrl.revokeObjectURL = originalRevokeObjectURL; + } else { + delete globalUrl.revokeObjectURL; + } + 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..91c1dee 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,32 @@ 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 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); + previewObjectUrl.current = null; + } + if (isObjectUrl && value) { + previewObjectUrl.current = value; + } + setPhotoPreview(value); + } useEffect(() => { setForm((prev) => ({ @@ -39,8 +66,43 @@ 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 (!ready || !session?.user?.email) { + return; + } + if (profile?.cubid_id || hasRequestedCubidId.current) { + return; + } + + hasRequestedCubidId.current = true; + requestCubidId(session.user.email) + .then((cubid) => { + if (latestProfileRef.current?.cubid_id) { + return; + } + 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, ready, session?.user?.email]); + if (!ready) { return (
@@ -50,23 +112,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…"); @@ -113,90 +158,297 @@ 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 }); + } 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 })); + updatePhotoPreview(publicUrl, false); + 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]?.split(";")[0]?.trim(); + 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",