Repository: gp-kobayashi/otetsudai_web
Pull request #42:  [feat]zodとuseFormを使用したバリデーションの追加
Author: login: gp-kobayashi
Created: 2025-05-08T06:33:27Z | Merged: 2025-05-15T07:28:19Z

zodとuseFormを使用しusernameの追加やプロフィール編集時にバリデーションを追加する
close #37 

Linked issue #37: [提案] フォーム入力のバリデーションに Zod を導入する
## 💡 提案内容  
バリデーションライブラリ**Zod**を導入して、フォーム入力にバリデーションを実装する。

## 🔍 背景  
Supabase をバックエンドに利用している関係で API 側にバリデーションがなく、現在は一部のフォームにのみ手動チェックを実装しています。  
これを **Zod** で共通スキーマを定義し、**全てのフォーム** でバリデーションを適用することで、型安全かつ保守性の高い入力チェックを実現したいです。

> *補足：必要に応じて `react-hook-form` + `@hookform/resolvers/zod` を併用すると、フォーム統合がさらにスムーズになります。*

## ✅ 期待される効果  
- **一元管理** によって全フォームへの適用漏れを防止  
- **型推論** で開発体験を強化し、リファクタリング時の安心感を向上  
- **エラーメッセージ** をスキーマ内で一元定義して UX を改善  

Diff:
diff --git a/app/account/account-form.tsx b/app/account/account-form.tsx
index 888a62e..d4985b9 100644
--- a/app/account/account-form.tsx
+++ b/app/account/account-form.tsx
@@ -4,15 +4,33 @@ import { createClient } from "@/utils/supabase/client";
 import { type User } from "@supabase/supabase-js";
 import Avatar from "./avatar";
 import styles from "./account.module.css";
-// ...
+import { profileSchema } from "@/utils/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+type ProfileFormData = z.infer<typeof profileSchema>;
 
 export default function AccountForm({ user }: { user: User | null }) {
   const supabase = createClient();
   const [loading, setLoading] = useState(true);
-  const [username, setUsername] = useState<string | null>(null);
-  const [website, setWebsite] = useState<string | null>(null);
   const [avatar_url, setAvatarUrl] = useState<string | null>(null);
-  const [bio, setBio] = useState<string | null>(null);
+  const {
+    register,
+    handleSubmit,
+    setValue,
+    reset,
+    formState: { errors, isValid },
+  } = useForm<ProfileFormData>({
+    resolver: zodResolver(profileSchema),
+    defaultValues: {
+      username: "",
+      website: "",
+      bio: "",
+      avatar_url: "",
+    },
+    mode: "onChange",
+  });
 
   const getProfile = useCallback(async () => {
     try {
@@ -30,120 +48,117 @@ export default function AccountForm({ user }: { user: User | null }) {
       }
 
       if (data) {
-        setUsername(data.username);
-        setWebsite(data.website);
+        reset({
+          username: data.username,
+          website: data.website,
+          bio: data.bio,
+          avatar_url: data.avatar_url,
+        });
         setAvatarUrl(data.avatar_url);
-        setBio(data.bio);
       }
     } finally {
       setLoading(false);
     }
-  }, [user, supabase]);
+  }, [user, supabase, reset]);
 
   useEffect(() => {
     getProfile();
   }, [user, getProfile]);
 
-  async function updateProfile({
-    username,
-    website,
-    avatar_url,
-  }: {
-    username: string | null;
-    website: string | null;
-    avatar_url: string | null;
-    bio: string | null;
-  }) {
+  const onSubmit = async (values: ProfileFormData) => {
     try {
       setLoading(true);
-
       const { error } = await supabase.from("profiles").upsert({
-        id: user?.id as string,
-        username,
-        website,
-        avatar_url,
+        id: user?.id,
+        ...values,
         updated_at: new Date().toISOString(),
-        bio,
       });
+
       if (error) {
         if (error.code === "23505") {
-          alert(
-            "そのユーザー名はすでに使用されています。別のユーザー名を選択してください。",
-          );
+          alert("そのユーザー名はすでに使用されています。");
         } else {
-          console.error(error);
           alert("プロフィールの更新中にエラーが発生しました。");
         }
         return;
       }
+
       alert("プロフィールが更新されました");
     } finally {
       setLoading(false);
     }
-  }
+  };
 
   return (
     <div className={styles.account_container}>
-      <Avatar
-        uid={user?.id ?? null}
-        url={avatar_url}
-        size={150}
-        onUpload={(url) => {
-          setAvatarUrl(url);
-          updateProfile({ username, website, avatar_url: url, bio });
-        }}
-      />
-      {/* ... */}
-
-      <div>
-        <label htmlFor="email">Email</label>
-        <input
-          id="email"
-          type="text"
-          value={user?.email}
-          disabled
-          className={styles.account_input}
-        />
-      </div>
-      <div>
-        <label htmlFor="username">Username</label>
-        <input
-          id="username"
-          type="text"
-          value={username || ""}
-          onChange={(e) => setUsername(e.target.value)}
-          className={styles.account_input}
-        />
-      </div>
-      <div>
-        <label htmlFor="website">Website</label>
-        <input
-          id="website"
-          type="url"
-          value={website || ""}
-          onChange={(e) => setWebsite(e.target.value)}
-          className={styles.account_input}
-        />
-      </div>
-      <div>
-        <label htmlFor="bio">自己紹介</label>
-        <textarea
-          id="bio"
-          value={bio || ""}
-          onChange={(e) => setBio(e.target.value)}
-          className={styles.account_textarea}
+      <form onSubmit={handleSubmit(onSubmit)}>
+        <Avatar
+          uid={user?.id ?? null}
+          url={avatar_url}
+          size={150}
+          onUpload={(url) => {
+            setAvatarUrl(url);
+            setValue("avatar_url", url);
+          }}
         />
-      </div>
-      <div>
-        <button
-          className={styles.account_btn}
-          onClick={() => updateProfile({ username, website, avatar_url, bio })}
-          disabled={loading}
-        >
-          {loading ? "Loading ..." : "Update"}
-        </button>
-      </div>
+        {/* ... */}
 
+        <div>
+          <label htmlFor="email">Email</label>
+          <input
+            id="email"
+            type="text"
+            value={user?.email}
+            disabled
+            className={styles.account_input}
+          />
+        </div>
+        <div>
+          <label htmlFor="username">Username</label>
+          <input
+            id="username"
+            type="text"
+            {...register("username", { required: "ユーザー名は必須です" })}
+            className={styles.account_input}
+          />
+          {errors.username && (
+            <p className={styles.account_error}>{errors.username.message}</p>
+          )}
+        </div>
+        <div>
+          <label htmlFor="website">Website</label>
+          <input
+            id="website"
+            type="url"
+            {...register("website")}
+            className={styles.account_input}
+          />
+          {errors.website && (
+            <p className={styles.account_error}>{errors.website.message}</p>
+          )}
+        </div>
+
+        <div>
+          <label htmlFor="bio">自己紹介</label>
+          <textarea
+            id="bio"
+            {...register("bio")}
+            className={styles.account_textarea}
+          />
+          {errors.bio && (
+            <p className={styles.account_error}>{errors.bio.message}</p>
+          )}
+        </div>
+        <div>
+          <button
+            className={`${styles.account_btn} ${!isValid && styles.disabled}`}
+            type="submit"
+            disabled={loading || !isValid}
+          >
+            {loading ? "Loading ..." : "Update"}
+          </button>
+        </div>
+      </form>
       <div>
         <form action="/auth/signout" method="post">
           <button className={styles.account_btn} type="submit">
diff --git a/app/account/account.module.css b/app/account/account.module.css
index e5cec55..55ec0a6 100644
--- a/app/account/account.module.css
+++ b/app/account/account.module.css
@@ -32,3 +32,13 @@
   border-radius: 5px;
   cursor: pointer;
 }
+.disabled {
+  background-color: #cccccc;
+  cursor: not-allowed;
+  opacity: 0.7;
+}
+.account_error {
+  color: red;
+  font-size: 14px;
+  margin: 5px 0;
+}
diff --git a/app/insertUserName/app.tsx b/app/insertUserName/app.tsx
index 78285ea..1f57e33 100644
--- a/app/insertUserName/app.tsx
+++ b/app/insertUserName/app.tsx
@@ -1,49 +1,57 @@
 "use client";
 import { insertUsername } from "@/app/supabase_function/profile";
 import { useRouter } from "next/navigation";
-import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { usernameSchema } from "@/utils/zod";
 import styles from "./inserUserName.module.css";
 type props = {
   user_id: string;
 };
+type UsernameFormData = z.infer<typeof usernameSchema>;
 
 const InsertUserNameApp = ({ user_id }: props) => {
   const router = useRouter();
-  const [username, setUsername] = useState<string>("");
 
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    const alphanumericRegex = /^[a-zA-Z0-9]+$/;
-    if (!alphanumericRegex.test(username)) {
-      alert("ユーザー名は英数字のみ使用できます");
-      return;
-    }
-    const { error } = await insertUsername(user_id, username);
+  const {
+    register,
+    handleSubmit,
+    formState: { errors, isValid },
+  } = useForm<UsernameFormData>({
+    resolver: zodResolver(usernameSchema),
+  });
+  const onSubmit = async (data: UsernameFormData) => {
+    const { error } = await insertUsername(user_id, data.username);
     if (error) {
       alert(
         "そのユーザー名はすでに使用されています。別のユーザー名を選択してください。",
       );
       return;
     }
-
     router.push("/account");
   };
   return (
     <div>
-      <form onSubmit={(e) => handleSubmit(e)}>
+      <form onSubmit={handleSubmit(onSubmit)}>
         <label htmlFor="username">User Name:</label>
         <input
           type="text"
           id="username"
-          name="username"
-          required
-          value={username}
-          onChange={(e) => setUsername(e.target.value)}
+          {...register("username")}
           className={styles.input}
         />
-        <button type="submit" className={styles.btn}>
+
+        <button
+          type="submit"
+          className={`${styles.btn} ${!isValid && styles.disabled}`}
+          disabled={!isValid}
+        >
           Submit
         </button>
+        {errors.username && (
+          <p className={styles.error}>{errors.username.message}</p>
+        )}
       </form>
     </div>
   );
diff --git a/app/insertUserName/inserUserName.module.css b/app/insertUserName/inserUserName.module.css
index 64c33cc..689ffed 100644
--- a/app/insertUserName/inserUserName.module.css
+++ b/app/insertUserName/inserUserName.module.css
@@ -31,3 +31,12 @@
   border-radius: 5px;
   cursor: pointer;
 }
+.disabled {
+  background-color: #ccc;
+  cursor: not-allowed;
+}
+.error {
+  color: red;
+  font-size: 14px;
+  margin: 5px 0;
+}
diff --git a/app/insertUserName/page.tsx b/app/insertUserName/page.tsx
index adfa06e..9b4dc25 100644
--- a/app/insertUserName/page.tsx
+++ b/app/insertUserName/page.tsx
@@ -23,7 +23,7 @@ const InsertUserName = async () => {
 
       <InsertUserNameApp user_id={user_id} />
       <p className={styles.attention}>
-        ※ユーザーネームは英数字のみ使用できます。
+        ※ユーザーネームは３文字以上で英数字のみ使用できます。
       </p>
     </div>
   );
diff --git a/package-lock.json b/package-lock.json
index 0b869d9..eb9b08c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,15 @@
       "name": "supabase-nextjs",
       "version": "0.1.0",
       "dependencies": {
+        "@hookform/resolvers": "^5.0.1",
         "@supabase/ssr": "^0.5.2",
         "@supabase/supabase-js": "^2.47.10",
         "next": "15.1.2",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
-        "react-icons": "^5.5.0"
+        "react-hook-form": "^7.56.3",
+        "react-icons": "^5.5.0",
+        "zod": "^3.24.4"
       },
       "devDependencies": {
         "@eslint/eslintrc": "^3",
@@ -153,6 +156,17 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@hookform/resolvers": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz",
+      "integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==",
+      "dependencies": {
+        "@standard-schema/utils": "^0.3.0"
+      },
+      "peerDependencies": {
+        "react-hook-form": "^7.55.0"
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -746,6 +760,11 @@
       "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==",
       "dev": true
     },
+    "node_modules/@standard-schema/utils": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+      "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
+    },
     "node_modules/@supabase/auth-js": {
       "version": "2.67.3",
       "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.67.3.tgz",
@@ -3766,6 +3785,21 @@
         "react": "^19.0.0"
       }
     },
+    "node_modules/react-hook-form": {
+      "version": "7.56.3",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz",
+      "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==",
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/react-hook-form"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17 || ^18 || ^19"
+      }
+    },
     "node_modules/react-icons": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -4652,6 +4686,14 @@
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/zod": {
+      "version": "3.24.4",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
+      "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index 866ccc7..74b1491 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,15 @@
     "lint": "next lint"
   },
   "dependencies": {
+    "@hookform/resolvers": "^5.0.1",
     "@supabase/ssr": "^0.5.2",
     "@supabase/supabase-js": "^2.47.10",
     "next": "15.1.2",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
-    "react-icons": "^5.5.0"
+    "react-hook-form": "^7.56.3",
+    "react-icons": "^5.5.0",
+    "zod": "^3.24.4"
   },
   "devDependencies": {
     "@eslint/eslintrc": "^3",
diff --git a/utils/zod.ts b/utils/zod.ts
new file mode 100644
index 0000000..d30a46b
--- /dev/null
+++ b/utils/zod.ts
@@ -0,0 +1,23 @@
+import {z} from "zod";
+
+
+export const usernameSchema = z.object({
+    username: z
+      .string()
+      .min(3, "ユーザー名は3文字以上で入力してください")
+      .max(20, "ユーザー名は20文字以内で入力してください")
+      .regex(/^[a-zA-Z0-9]+$/, "ユーザー名は英数字のみ使用できます"),
+  });
+
+export const profileSchema = z.object({
+    username: usernameSchema.shape.username,
+    website: z
+      .string()
+      .url("正しいURL形式で入力してください")
+      .nullable()
+      .or(z.literal("")), 
+    avatar_url: z.string().nullable().or(z.literal("")),
+    bio: z.string().nullable().or(z.literal(""))
+  });
+
+   
\ No newline at end of file


Reviews:
user: login: gp-n-suzuki
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T06:04:35Z
user: login: gp-n-suzuki
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T05:43:58Z
user: login: gp-n-suzuki
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T05:44:09Z
user: login: gp-n-suzuki
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T05:46:36Z
user: login: gp-n-suzuki
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T07:11:58Z

Review comments:
diff_hunk: @@ -0,0 +1,24 @@
+import {z} from "zod";
+
+export const profileSchema = z.object({
+    username: z
path: app/utils/zod.ts
user: login: gp-n-suzuki
body: `username` のバリデーションは `usernameSchema` として定義されているため、`profileSchema` 側でもそれを参照する形にすると、バリデーションルールの重複を避けることができます。
そうすることで保守性が高まり、今後ルールを変更する際にも一箇所を修正するだけで済むようになるため、より管理しやすくなると思います。
created_at: 2025-05-09T06:04:34Z
author_association: COLLABORATOR
diff_hunk: @@ -32,3 +32,19 @@
   border-radius: 5px;
   cursor: pointer;
 }
+.account_btn_disabled {
path: app/account/account.module.css
user: login: gp-n-suzuki
body: `.account_btn` と `.account_btn_disabled` をクラス名ごとに切り替える実装になっていますが、差分部分のみ追加する形にすると、他のスタイルとの一貫性が保たれ、保守性も高くなると思います。
created_at: 2025-05-12T05:43:58Z
author_association: COLLABORATOR
diff_hunk: @@ -32,3 +32,19 @@
   border-radius: 5px;
   cursor: pointer;
 }
+.account_btn_disabled {
+  width: 100%;
+  max-width: 600px;
+  padding: 10px;
+  margin: 15px 0;
+  color: white;
+  border: none;
+  border-radius: 5px;
+  cursor: pointer;
+  background-color: #0056b3;
path: app/account/account.module.css
user: login: gp-n-suzuki
body: これは完全に個人的な感想ですが、ボタンが押せないときには灰色で表示されている方が視覚的に分かりやすいと思います。
また、カーソルに禁止マーク（`not-allowed`）が表示されると、クリックできないことが直感的に伝わって、より使いやすいと感じます。

そのため、以下のようなスタイルはどうですか？

```css
background-color: #cccccc;
cursor: not-allowed;
opacity: 0.7;
```
created_at: 2025-05-12T05:44:09Z
author_association: COLLABORATOR
diff_hunk: @@ -30,120 +48,117 @@ export default function AccountForm({ user }: { user: User | null }) {
       }
 
       if (data) {
-        setUsername(data.username);
-        setWebsite(data.website);
+        reset({
+          username: data.username,
+          website: data.website,
+          bio: data.bio,
+          avatar_url: data.avatar_url,
+        });
         setAvatarUrl(data.avatar_url);
-        setBio(data.bio);
       }
     } finally {
       setLoading(false);
     }
-  }, [user, supabase]);
+  }, [user, supabase, reset]);
 
   useEffect(() => {
     getProfile();
   }, [user, getProfile]);
 
-  async function updateProfile({
-    username,
-    website,
-    avatar_url,
-  }: {
-    username: string | null;
-    website: string | null;
-    avatar_url: string | null;
-    bio: string | null;
-  }) {
+  const onSubmit = async (values: ProfileFormData) => {
     try {
       setLoading(true);
-
       const { error } = await supabase.from("profiles").upsert({
-        id: user?.id as string,
-        username,
-        website,
-        avatar_url,
+        id: user?.id,
+        ...values,
         updated_at: new Date().toISOString(),
-        bio,
       });
+
       if (error) {
         if (error.code === "23505") {
-          alert(
-            "そのユーザー名はすでに使用されています。別のユーザー名を選択してください。",
-          );
+          alert("そのユーザー名はすでに使用されています。");
         } else {
-          console.error(error);
           alert("プロフィールの更新中にエラーが発生しました。");
         }
         return;
       }
+
       alert("プロフィールが更新されました");
     } finally {
       setLoading(false);
     }
-  }
+  };
 
   return (
     <div className={styles.account_container}>
-      <Avatar
-        uid={user?.id ?? null}
-        url={avatar_url}
-        size={150}
-        onUpload={(url) => {
-          setAvatarUrl(url);
-          updateProfile({ username, website, avatar_url: url, bio });
-        }}
-      />
-      {/* ... */}
-
-      <div>
-        <label htmlFor="email">Email</label>
-        <input
-          id="email"
-          type="text"
-          value={user?.email}
-          disabled
-          className={styles.account_input}
-        />
-      </div>
-      <div>
-        <label htmlFor="username">Username</label>
-        <input
-          id="username"
-          type="text"
-          value={username || ""}
-          onChange={(e) => setUsername(e.target.value)}
-          className={styles.account_input}
-        />
-      </div>
-      <div>
-        <label htmlFor="website">Website</label>
-        <input
-          id="website"
-          type="url"
-          value={website || ""}
-          onChange={(e) => setWebsite(e.target.value)}
-          className={styles.account_input}
-        />
-      </div>
-      <div>
-        <label htmlFor="bio">自己紹介</label>
-        <textarea
-          id="bio"
-          value={bio || ""}
-          onChange={(e) => setBio(e.target.value)}
-          className={styles.account_textarea}
+      <form onSubmit={handleSubmit(onSubmit)}>
+        <Avatar
+          uid={user?.id ?? null}
+          url={avatar_url}
+          size={150}
+          onUpload={(url) => {
+            setAvatarUrl(url);
+            setValue("avatar_url", url);
+          }}
         />
-      </div>
-      <div>
-        <button
-          className={styles.account_btn}
-          onClick={() => updateProfile({ username, website, avatar_url, bio })}
-          disabled={loading}
-        >
-          {loading ? "Loading ..." : "Update"}
-        </button>
-      </div>
+        {/* ... */}
 
+        <div>
+          <label htmlFor="email">Email</label>
+          <input
+            id="email"
+            type="text"
+            value={user?.email}
+            disabled
+            className={styles.account_input}
+          />
+        </div>
+        <div>
+          <label htmlFor="username">Username</label>
+          <input
+            id="username"
+            type="text"
+            {...register("username", { required: "ユーザー名は必須です" })}
+            className={styles.account_input}
+          />
+          {errors.username && (
+            <p className={styles.account_error}>{errors.username.message}</p>
+          )}
+        </div>
+        <div>
+          <label htmlFor="website">Website</label>
+          <input
+            id="website"
+            type="url"
+            {...register("website")}
+            className={styles.account_input}
+          />
+          {errors.website && (
+            <p className={styles.account_error}>{errors.website.message}</p>
+          )}
+        </div>
+
+        <div>
+          <label htmlFor="bio">自己紹介</label>
+          <textarea
+            id="bio"
+            {...register("bio")}
+            className={styles.account_textarea}
+          />
+          {errors.bio && (
+            <p className={styles.account_error}>{errors.bio.message}</p>
+          )}
+        </div>
+        <div>
+          <button
+            className={`${styles.account_btn} ${!isValid ? styles.disabled : ""}`}
path: app/account/account-form.tsx
user: login: gp-n-suzuki
body: すこし細かい点ですが、`${!isValid && styles.disabled}` とすることで、よりスッキリと記述できると思います。
created_at: 2025-05-14T05:46:35Z
author_association: COLLABORATOR
diff_hunk: @@ -1,49 +1,57 @@
 "use client";
 import { insertUsername } from "@/app/supabase_function/profile";
 import { useRouter } from "next/navigation";
-import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { usernameSchema } from "@/utils/zod";
 import styles from "./inserUserName.module.css";
 type props = {
   user_id: string;
 };
+type UsernameFormData = z.infer<typeof usernameSchema>;
 
 const InsertUserNameApp = ({ user_id }: props) => {
   const router = useRouter();
-  const [username, setUsername] = useState<string>("");
 
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    const alphanumericRegex = /^[a-zA-Z0-9]+$/;
-    if (!alphanumericRegex.test(username)) {
-      alert("ユーザー名は英数字のみ使用できます");
-      return;
-    }
-    const { error } = await insertUsername(user_id, username);
+  const {
+    register,
+    handleSubmit,
+    formState: { errors, isValid },
+  } = useForm<UsernameFormData>({
+    resolver: zodResolver(usernameSchema),
+  });
+  const onSubmit = async (data: UsernameFormData) => {
+    const { error } = await insertUsername(user_id, data.username);
     if (error) {
       alert(
         "そのユーザー名はすでに使用されています。別のユーザー名を選択してください。",
       );
       return;
     }
-
     router.push("/account");
   };
   return (
     <div>
-      <form onSubmit={(e) => handleSubmit(e)}>
+      <form onSubmit={handleSubmit(onSubmit)}>
         <label htmlFor="username">User Name:</label>
         <input
           type="text"
           id="username"
-          name="username"
-          required
-          value={username}
-          onChange={(e) => setUsername(e.target.value)}
+          {...register("username")}
           className={styles.input}
         />
-        <button type="submit" className={styles.btn}>
+
+        <button
+          type="submit"
+          className={`${styles.btn} ${!isValid && styles.disabled}`}
+          disabled={!isValid}
+        >
           Submit
         </button>
+        {errors.username && (
+          <p className={styles.account_error}>{errors.username.message}</p>
path: app/insertUserName/app.tsx
user: login: gp-n-suzuki
body: ここ、スタイルが指定されておらず、文字色が黒くなっているようです。
おそらく `styles.error` ではないでしょうか？
created_at: 2025-05-15T07:11:58Z
author_association: COLLABORATOR

PR comments:
user: login: gp-n-suzuki
created_at: 2025-05-09T06:04:39Z
body: 現在、`zod.ts` は `app/utils/` 配下に配置されていますが、このプロジェクトではすでにルートディレクトリ直下にも `utils` ディレクトリが存在しているため、
そちらに統一して配置する方がディレクトリ構成に一貫性があり、管理もしやすくなると思います。


author_association: COLLABORATOR
user: login: gp-n-suzuki
created_at: 2025-05-09T06:05:04Z
body: これは好みの問題もありますが、バリデーションエラーが発生している間はボタンを非活性化しておくと、
ユーザーにとって状態がわかりやすくなり、操作ミスの防止にもつながるため、ユーザー体験の向上が期待できると思います。
author_association: COLLABORATOR
user: login: gp-n-suzuki
created_at: 2025-05-12T05:43:51Z
body: 修正確認しました。
usernameの追加時(insertUserName)でも同様に非活性化の対応がされていると、入力中の誤操作を防ぎやすくなり、ユーザー体験の向上につながると思います。
author_association: COLLABORATOR
user: login: gp-n-suzuki
created_at: 2025-05-15T02:22:30Z
body: プロフィール編集画面では、未入力時に送信ボタンが非活性になっていると思うのですが、`insertUserName`（新規登録後のユーザーネーム入力画面）ではその処理が入っていないですが、
これは何か意図があってあえて非活性にしていない感じでしょうか？
もし特に理由がなければ、他と同様にしておくと操作の一貫性が出て使いやすくなると思います。


author_association: COLLABORATOR
user: login: gp-n-suzuki
created_at: 2025-05-15T07:11:55Z
body: 修正内容を確認しました。
細かい修正とコンフリクトの解消ができれば、このままマージして問題ないと思います。

また、`insertUserName` 画面では、基本的にバリデーションエラー文が表示されることはない（ボタンが非活性のため）ので、
「※ユーザーネームは英数字のみ使用できます。」という文言に加えて、\*\*「3文字以上が必要です」\*\*という条件も明記しておくと、より親切かと思います。

author_association: COLLABORATOR
user: login: gp-n-suzuki
created_at: 2025-05-15T07:15:34Z
body: 今更ですが、修正内容的に [fix] ではなく [feat] を使った方がより適切かもしれません。
author_association: COLLABORATOR