Skip to content

Commit 3d2e2e9

Browse files
committed
fix: harden new user onboarding flow
1 parent 006a27a commit 3d2e2e9

File tree

3 files changed

+58
-26
lines changed

3 files changed

+58
-26
lines changed

frontend/__tests__/new-user-page.test.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const {
2626
revokeObjectURLMock: vi.fn(),
2727
}));
2828
let originalFetch: typeof globalThis.fetch;
29+
let originalCreateObjectURL: typeof URL.createObjectURL | undefined;
30+
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined;
2931

3032
vi.mock("next/navigation", () => ({
3133
useRouter: () => ({
@@ -91,6 +93,8 @@ describe("NewUserPage", () => {
9193
createObjectURLMock.mockReturnValue("blob:preview");
9294
revokeObjectURLMock.mockReset();
9395
const globalUrl = globalThis.URL as unknown as Record<string, unknown>;
96+
originalCreateObjectURL = globalUrl.createObjectURL as typeof URL.createObjectURL | undefined;
97+
originalRevokeObjectURL = globalUrl.revokeObjectURL as typeof URL.revokeObjectURL | undefined;
9498
globalUrl.createObjectURL = createObjectURLMock;
9599
globalUrl.revokeObjectURL = revokeObjectURLMock;
96100

@@ -102,8 +106,16 @@ describe("NewUserPage", () => {
102106
afterEach(() => {
103107
globalThis.fetch = originalFetch;
104108
const globalUrl = globalThis.URL as unknown as Record<string, unknown>;
105-
globalUrl.createObjectURL = createObjectURLMock;
106-
globalUrl.revokeObjectURL = revokeObjectURLMock;
109+
if (originalCreateObjectURL) {
110+
globalUrl.createObjectURL = originalCreateObjectURL;
111+
} else {
112+
delete globalUrl.createObjectURL;
113+
}
114+
if (originalRevokeObjectURL) {
115+
globalUrl.revokeObjectURL = originalRevokeObjectURL;
116+
} else {
117+
delete globalUrl.revokeObjectURL;
118+
}
107119
upsertMyProfileMock.mockReset();
108120
requestCubidIdMock.mockReset();
109121
ensureWalletMock.mockReset();

frontend/src/app/new-user/page.tsx

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,13 @@ export default function NewUserPage() {
4242
const [photoLink, setPhotoLink] = useState("");
4343
const [photoPreview, setPhotoPreview] = useState<string | null>(profile?.photo_url ?? null);
4444
const hasRequestedCubidId = useRef(false);
45+
const latestProfileRef = useRef(profile);
4546
const previewObjectUrl = useRef<string | null>(null);
4647

48+
useEffect(() => {
49+
latestProfileRef.current = profile;
50+
}, [profile]);
51+
4752
function updatePhotoPreview(value: string | null, isObjectUrl: boolean) {
4853
if (previewObjectUrl.current) {
4954
URL.revokeObjectURL(previewObjectUrl.current);
@@ -76,7 +81,7 @@ export default function NewUserPage() {
7681
}, []);
7782

7883
useEffect(() => {
79-
if (!session?.user?.email) {
84+
if (!ready || !session?.user?.email) {
8085
return;
8186
}
8287
if (profile?.cubid_id || hasRequestedCubidId.current) {
@@ -86,14 +91,17 @@ export default function NewUserPage() {
8691
hasRequestedCubidId.current = true;
8792
requestCubidId(session.user.email)
8893
.then((cubid) => {
94+
if (latestProfileRef.current?.cubid_id) {
95+
return;
96+
}
8997
setForm((prev) => ({ ...prev, cubidId: cubid }));
9098
setStatus("Cubid ID prepared");
9199
})
92100
.catch((err) => {
93101
const message = err instanceof Error ? err.message : "Failed to generate Cubid ID";
94102
setError(message);
95103
});
96-
}, [profile?.cubid_id, session?.user?.email]);
104+
}, [profile?.cubid_id, ready, session?.user?.email]);
97105

98106
if (!ready) {
99107
return (
@@ -113,7 +121,6 @@ export default function NewUserPage() {
113121
setUser(updated);
114122
setWalletAddress(address);
115123
setStatus("Wallet linked");
116-
setStep(2);
117124
} catch (err) {
118125
const message = err instanceof Error ? err.message : "Wallet connection failed";
119126
setError(message);
@@ -184,13 +191,12 @@ export default function NewUserPage() {
184191
} else if (photoLink) {
185192
const response = await fetch(photoLink);
186193
if (!response.ok) {
187-
throw new Error("We couldn&apos;t fetch that image link");
194+
throw new Error("We couldn't fetch that image link");
188195
}
189196
const contentType = response.headers.get("content-type") ?? "image/jpeg";
190197
const extension = inferExtensionFromSource(photoLink, contentType);
191198
const blob = await response.blob();
192199
fileToUpload = new File([blob], `linked.${extension}`, { type: contentType });
193-
updatePhotoPreview(URL.createObjectURL(blob), true);
194200
} else if (form.photoUrl) {
195201
// Existing profile photo already stored in Supabase.
196202
setStatus("Photo ready");
@@ -218,6 +224,7 @@ export default function NewUserPage() {
218224
} = supabase.storage.from("profile-pictures").getPublicUrl(storagePath);
219225

220226
setForm((prev) => ({ ...prev, photoUrl: publicUrl }));
227+
updatePhotoPreview(publicUrl, false);
221228
setStatus("Photo uploaded");
222229
}
223230

@@ -233,7 +240,7 @@ export default function NewUserPage() {
233240
await uploadPhotoFromLinkOrFile();
234241
setStep(2);
235242
} catch (err) {
236-
const message = err instanceof Error ? err.message : "We couldn&apos;t save your photo";
243+
const message = err instanceof Error ? err.message : "We couldn't save your photo";
237244
setError(message);
238245
setStatus(null);
239246
} finally {
@@ -250,22 +257,39 @@ export default function NewUserPage() {
250257
<header className="space-y-2">
251258
<h1 className="text-3xl font-semibold">Welcome to Trust Me Bro</h1>
252259
<p className="text-muted-foreground">
253-
We&apos;ll gather a few details to build your profile: your name, a photo, and your wallet.
260+
We'll gather a few details to build your profile: your name, a photo, and your wallet.
254261
</p>
255262
</header>
256263

257-
<div className="flex gap-2 text-xs uppercase tracking-widest text-muted-foreground">
258-
<span className={step === 0 ? "font-semibold text-blue-600" : ""}>Name</span>
259-
<span></span>
260-
<span className={step === 1 ? "font-semibold text-blue-600" : step > 1 ? "text-blue-600" : ""}>Photo</span>
261-
<span></span>
262-
<span className={step === 2 ? "font-semibold text-blue-600" : ""}>Wallet</span>
263-
</div>
264+
<nav aria-label="Onboarding progress">
265+
<ol className="flex items-center gap-2 text-xs uppercase tracking-widest text-muted-foreground">
266+
{(["Name", "Photo", "Wallet"] as const).map((label, index, array) => (
267+
<li
268+
key={label}
269+
aria-current={step === index ? "step" : undefined}
270+
className={`flex items-center ${
271+
step === index
272+
? "font-semibold text-blue-600"
273+
: index < step
274+
? "text-blue-600"
275+
: ""
276+
}`}
277+
>
278+
<span>{label}</span>
279+
{index < array.length - 1 ? (
280+
<span aria-hidden="true" className="px-1 text-muted-foreground">
281+
282+
</span>
283+
) : null}
284+
</li>
285+
))}
286+
</ol>
287+
</nav>
264288

265289
{step === 0 ? (
266290
<form className="space-y-6" onSubmit={handleNameNext}>
267291
<label className="flex flex-col gap-3">
268-
<span className="text-2xl font-medium">What&apos;s your name?</span>
292+
<span className="text-2xl font-medium">What's your name?</span>
269293
<input
270294
autoFocus
271295
className="w-full rounded-lg border border-neutral-300 bg-white px-4 py-6 text-2xl shadow-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
@@ -291,7 +315,7 @@ export default function NewUserPage() {
291315
<div className="space-y-3">
292316
<p className="text-2xl font-medium">Share a photo</p>
293317
<p className="text-sm text-muted-foreground">
294-
Upload a file or paste a link to an image. We&apos;ll store it securely for your profile.
318+
Upload a file or paste a link to an image. We'll store it securely for your profile.
295319
</p>
296320
<div className="flex flex-col gap-4 rounded-lg border border-dashed border-neutral-300 p-6 dark:border-neutral-700">
297321
<label className="flex flex-col gap-2 text-sm font-medium">
@@ -331,6 +355,7 @@ export default function NewUserPage() {
331355
className="rounded-full border border-neutral-300 px-8 py-3 text-lg font-semibold transition hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-900"
332356
onClick={() => {
333357
setStatus(null);
358+
setError(null);
334359
setStep(0);
335360
}}
336361
type="button"
@@ -353,7 +378,7 @@ export default function NewUserPage() {
353378
<div className="space-y-3">
354379
<p className="text-2xl font-medium">Connect your wallet</p>
355380
<p className="text-sm text-muted-foreground">
356-
Link the wallet you&apos;ll use for vouching. Once connected, we&apos;ll confirm your Cubid ID.
381+
Link the wallet you'll use for vouching. Once connected, we'll confirm your Cubid ID.
357382
</p>
358383
<div className="rounded-lg border border-neutral-200 p-6 shadow-sm dark:border-neutral-800">
359384
<button
@@ -392,6 +417,7 @@ export default function NewUserPage() {
392417
className="rounded-full border border-neutral-300 px-8 py-3 text-lg font-semibold transition hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-900"
393418
onClick={() => {
394419
setStatus(null);
420+
setError(null);
395421
setStep(1);
396422
}}
397423
type="button"
@@ -420,7 +446,7 @@ function inferExtensionFromSource(source: string, contentType: string): string {
420446
if (urlExtension && /^[a-z0-9]+$/i.test(urlExtension)) {
421447
return urlExtension;
422448
}
423-
const mimeExtension = contentType.split("/")[1];
449+
const mimeExtension = contentType.split("/")[1]?.split(";")[0]?.trim();
424450
if (mimeExtension) {
425451
return mimeExtension;
426452
}

frontend/vitest.setup.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
11
import "@testing-library/jest-dom/vitest";
2-
3-
import { vi } from "vitest";
4-
5-
vi.mock("jsqr", () => ({
6-
default: vi.fn(),
7-
}));

0 commit comments

Comments
 (0)