Skip to content

Commit 68dfea4

Browse files
committed
feat: introduce react-hook-form (+zod) in profile edit page (closes #784)
Installs react-hook-form, @hookform/resolvers, and zod. Refactors profile/[username]/edit/page.tsx's ~10 useState declarations for form fields/errors into a single useForm<EditProfileFormValues>() with a zod schema mirroring the exact validation rules from the removed validate() function (same messages, same regexes). reset() populates the form once the profile loads (async ownership check); register() replaces the manual setField()/onChange wiring; handleSubmit(onSubmit) replaces the manual validate-then-submit flow. Per the issue, this starts with edit/page.tsx as the smaller scope; create/page.tsx (~15 useState, 3-step form) is a follow-up. The accepted-assets list (dynamic add/remove, not a simple field) is left as local state — not a good fit for react-hook-form's field model.
1 parent 38e7603 commit 68dfea4

1 file changed

Lines changed: 74 additions & 82 deletions

File tree

  • frontend/src/app/profile/[username]/edit

frontend/src/app/profile/[username]/edit/page.tsx

Lines changed: 74 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { useEffect, useState } from "react";
44
import { useParams, useRouter } from "next/navigation";
55
import Link from "next/link";
6+
import { useForm } from "react-hook-form";
7+
import { zodResolver } from "@hookform/resolvers/zod";
8+
import { z } from "zod";
69
import { getAddress } from "@stellar/freighter-api";
710
import { AppShell } from "@/components/app-shell";
811
import { API_BASE_URL } from "@/lib/config";
@@ -23,38 +26,44 @@ type ProfileData = {
2326
acceptedAssets: Array<{ code: string; issuer?: string | null }>;
2427
};
2528

26-
type FieldErrors = {
27-
displayName?: string;
28-
bio?: string;
29-
websiteUrl?: string;
30-
twitterHandle?: string;
31-
githubHandle?: string;
32-
email?: string;
29+
const editProfileSchema = z.object({
30+
displayName: z
31+
.string()
32+
.trim()
33+
.min(1, "Display name is required.")
34+
.max(64, "Max 64 characters."),
35+
bio: z.string().max(280, "Max 280 characters."),
36+
websiteUrl: z
37+
.string()
38+
.refine((v) => v === "" || /^https:\/\/.+/.test(v), "Must start with https://"),
39+
twitterHandle: z
40+
.string()
41+
.refine(
42+
(v) => v === "" || /^[a-zA-Z0-9_]{1,15}$/.test(v),
43+
"Max 15 chars, alphanumeric and underscores only.",
44+
),
45+
githubHandle: z
46+
.string()
47+
.refine(
48+
(v) => v === "" || /^[a-zA-Z0-9-]{1,39}$/.test(v),
49+
"Max 39 chars, alphanumeric and hyphens only.",
50+
),
51+
email: z
52+
.string()
53+
.refine((v) => v === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), "Enter a valid email address."),
54+
});
55+
56+
type EditProfileFormValues = z.infer<typeof editProfileSchema>;
57+
58+
const EMPTY_FORM: EditProfileFormValues = {
59+
displayName: "",
60+
bio: "",
61+
websiteUrl: "",
62+
twitterHandle: "",
63+
githubHandle: "",
64+
email: "",
3365
};
3466

35-
function validate(form: {
36-
displayName: string;
37-
bio: string;
38-
websiteUrl: string;
39-
twitterHandle: string;
40-
githubHandle: string;
41-
email: string;
42-
}): FieldErrors {
43-
const errors: FieldErrors = {};
44-
if (!form.displayName.trim()) errors.displayName = "Display name is required.";
45-
else if (form.displayName.length > 64) errors.displayName = "Max 64 characters.";
46-
if (form.bio.length > 280) errors.bio = "Max 280 characters.";
47-
if (form.websiteUrl && !/^https:\/\/.+/.test(form.websiteUrl))
48-
errors.websiteUrl = "Must start with https://";
49-
if (form.twitterHandle && !/^[a-zA-Z0-9_]{1,15}$/.test(form.twitterHandle))
50-
errors.twitterHandle = "Max 15 chars, alphanumeric and underscores only.";
51-
if (form.githubHandle && !/^[a-zA-Z0-9-]{1,39}$/.test(form.githubHandle))
52-
errors.githubHandle = "Max 39 chars, alphanumeric and hyphens only.";
53-
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
54-
errors.email = "Enter a valid email address.";
55-
return errors;
56-
}
57-
5867
export default function EditProfilePage() {
5968
const { username } = useParams<{ username: string }>();
6069
const router = useRouter();
@@ -65,16 +74,19 @@ export default function EditProfilePage() {
6574
const [submitting, setSubmitting] = useState(false);
6675
const [authError, setAuthError] = useState<string | null>(null);
6776
const [walletPrompt, setWalletPrompt] = useState<"locked" | "not-owner" | null>(null);
68-
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
69-
70-
const [form, setForm] = useState({
71-
displayName: "",
72-
bio: "",
73-
websiteUrl: "",
74-
twitterHandle: "",
75-
githubHandle: "",
76-
email: "",
77+
78+
const {
79+
register,
80+
handleSubmit,
81+
reset,
82+
watch,
83+
formState: { errors: fieldErrors },
84+
} = useForm<EditProfileFormValues>({
85+
resolver: zodResolver(editProfileSchema),
86+
mode: "onChange",
87+
defaultValues: EMPTY_FORM,
7788
});
89+
const bioValue = watch("bio");
7890

7991
const [assets, setAssets] = useState<Asset[]>([]);
8092
const [newAssetCode, setNewAssetCode] = useState("");
@@ -108,7 +120,7 @@ export default function EditProfilePage() {
108120
}
109121

110122
setOwnershipChecked(true);
111-
setForm({
123+
reset({
112124
displayName: profile.displayName ?? "",
113125
bio: profile.bio ?? "",
114126
websiteUrl: profile.websiteUrl ?? "",
@@ -129,14 +141,7 @@ export default function EditProfilePage() {
129141
}
130142
}
131143
init();
132-
}, [username, router, ownershipChecked]);
133-
134-
function setField(field: keyof typeof form) {
135-
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
136-
setForm((prev) => ({ ...prev, [field]: e.target.value }));
137-
setFieldErrors((prev) => ({ ...prev, [field]: undefined }));
138-
};
139-
}
144+
}, [username, router, ownershipChecked, reset]);
140145

141146
function addAsset() {
142147
const code = newAssetCode.trim().toUpperCase();
@@ -153,23 +158,16 @@ export default function EditProfilePage() {
153158
setAssets((prev) => prev.filter((a) => a.code !== code));
154159
}
155160

156-
async function handleSubmit(e: React.FormEvent) {
157-
e.preventDefault();
158-
const errors = validate(form);
159-
if (Object.keys(errors).length > 0) {
160-
setFieldErrors(errors);
161-
return;
162-
}
163-
161+
async function onSubmit(values: EditProfileFormValues) {
164162
setSubmitting(true);
165163
try {
166164
const profilePayload: Record<string, string | null> = {
167-
displayName: form.displayName,
168-
bio: form.bio || "",
169-
websiteUrl: form.websiteUrl || null,
170-
twitterHandle: form.twitterHandle || null,
171-
githubHandle: form.githubHandle || null,
172-
email: form.email || null,
165+
displayName: values.displayName,
166+
bio: values.bio || "",
167+
websiteUrl: values.websiteUrl || null,
168+
twitterHandle: values.twitterHandle || null,
169+
githubHandle: values.githubHandle || null,
170+
email: values.email || null,
173171
};
174172

175173
const [profileRes, assetsRes] = await Promise.all([
@@ -291,7 +289,7 @@ export default function EditProfilePage() {
291289
</Link>
292290
</div>
293291

294-
<form onSubmit={handleSubmit} noValidate className="space-y-8">
292+
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-8">
295293
{/* Profile fields */}
296294
<section className="rounded-3xl border border-white/10 bg-white/[0.02] p-6 space-y-5">
297295
<h2 className="text-xs font-semibold uppercase tracking-widest text-steel">
@@ -301,69 +299,63 @@ export default function EditProfilePage() {
301299
<Field
302300
label="Display Name"
303301
required
304-
error={fieldErrors.displayName}
302+
error={fieldErrors.displayName?.message}
305303
>
306304
<input
307305
type="text"
308-
value={form.displayName}
309-
onChange={setField("displayName")}
306+
{...register("displayName")}
310307
maxLength={64}
311308
className={inputCls(!!fieldErrors.displayName)}
312309
placeholder="Your name"
313310
/>
314311
</Field>
315312

316-
<Field label="Bio" error={fieldErrors.bio}>
313+
<Field label="Bio" error={fieldErrors.bio?.message}>
317314
<textarea
318-
value={form.bio}
319-
onChange={setField("bio")}
315+
{...register("bio")}
320316
maxLength={280}
321317
rows={3}
322318
className={inputCls(!!fieldErrors.bio)}
323319
placeholder="Tell supporters about yourself (max 280 chars)"
324320
/>
325321
<p className="mt-1 text-right text-[10px] text-steel">
326-
{form.bio.length}/280
322+
{bioValue.length}/280
327323
</p>
328324
</Field>
329325

330-
<Field label="Website URL" error={fieldErrors.websiteUrl}>
326+
<Field label="Website URL" error={fieldErrors.websiteUrl?.message}>
331327
<input
332328
type="url"
333-
value={form.websiteUrl}
334-
onChange={setField("websiteUrl")}
329+
{...register("websiteUrl")}
335330
className={inputCls(!!fieldErrors.websiteUrl)}
336331
placeholder="https://yoursite.com"
337332
/>
338333
</Field>
339334

340-
<Field label="Twitter Handle" error={fieldErrors.twitterHandle}>
335+
<Field label="Twitter Handle" error={fieldErrors.twitterHandle?.message}>
341336
<input
342337
type="text"
343-
value={form.twitterHandle}
344-
onChange={setField("twitterHandle")}
338+
{...register("twitterHandle")}
345339
maxLength={15}
346340
className={inputCls(!!fieldErrors.twitterHandle)}
347341
placeholder="username (no @)"
348342
/>
349343
</Field>
350344

351-
<Field label="GitHub Handle" error={fieldErrors.githubHandle}>
345+
<Field label="GitHub Handle" error={fieldErrors.githubHandle?.message}>
352346
<input
353347
type="text"
354-
value={form.githubHandle}
355-
onChange={setField("githubHandle")}
348+
{...register("githubHandle")}
356349
maxLength={39}
357350
className={inputCls(!!fieldErrors.githubHandle)}
358351
placeholder="username"
359352
/>
360353
</Field>
361354

362-
<Field label="Email" error={fieldErrors.email}>
355+
<Field label="Email" error={fieldErrors.email?.message}>
363356
<input
364357
type="email"
365-
value={form.email}
366-
onChange={setField("email")}
358+
{...register("email")}
367359
className={inputCls(!!fieldErrors.email)}
368360
placeholder="you@example.com"
369361
/>

0 commit comments

Comments
 (0)