Skip to content

Commit 49c6030

Browse files
Merge pull request #36 from kmc-jp/user-registration
ユーザー登録機能を実装。ヘッダーの「ログイン」を(一時的に)「ユーザー登録」に変更
2 parents 0729be1 + af902bf commit 49c6030

File tree

3 files changed

+341
-7
lines changed

3 files changed

+341
-7
lines changed

src/app/auth/page.tsx

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
"use client";
2+
3+
import { useState, useId } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { registerUser } from "@/lib/api";
6+
import { ApiError } from "@/lib/types";
7+
8+
export default function AuthPage() {
9+
const router = useRouter();
10+
const [formData, setFormData] = useState({
11+
name: "",
12+
display_name: "",
13+
intro: "",
14+
email: "",
15+
show_email: false,
16+
password: "",
17+
confirmPassword: "",
18+
});
19+
const [errors, setErrors] = useState<Record<string, string>>({});
20+
const [isLoading, setIsLoading] = useState(false);
21+
const [apiError, setApiError] = useState<string | null>(null);
22+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
23+
const nameId = useId();
24+
const display_nameId = useId();
25+
const introId = useId();
26+
const emailId = useId();
27+
const show_emailId = useId();
28+
const passwordId = useId();
29+
const confirmPasswordId = useId();
30+
31+
const validateForm = () => {
32+
const newErrors: Record<string, string> = {};
33+
34+
if (!formData.name) {
35+
newErrors.name = "ユーザー名は必須です";
36+
} else if (!/^[a-zA-Z0-9]+$/.test(formData.name)) {
37+
newErrors.name = "ユーザー名は英数字のみ使用できます";
38+
}
39+
40+
if (!formData.display_name) {
41+
newErrors.display_name = "表示名は必須です";
42+
}
43+
44+
if (!formData.email) {
45+
newErrors.email = "メールアドレスは必須です";
46+
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
47+
newErrors.email = "有効なメールアドレスを入力してください";
48+
}
49+
50+
if (!formData.password) {
51+
newErrors.password = "パスワードは必須です";
52+
} else if (formData.password.length < 6) {
53+
newErrors.password = "パスワードは6文字以上である必要があります";
54+
}
55+
56+
if (formData.password !== formData.confirmPassword) {
57+
newErrors.confirmPassword = "パスワードが一致しません";
58+
}
59+
60+
setErrors(newErrors);
61+
return Object.keys(newErrors).length === 0;
62+
};
63+
64+
const handleSubmit = async (e: React.FormEvent) => {
65+
e.preventDefault();
66+
setApiError(null);
67+
setSuccessMessage(null);
68+
69+
if (!validateForm()) {
70+
return;
71+
}
72+
73+
setIsLoading(true);
74+
75+
try {
76+
const userData = {
77+
name: formData.name,
78+
display_name: formData.display_name,
79+
intro: formData.intro,
80+
email: formData.email,
81+
show_email: formData.show_email,
82+
password: formData.password, // 注意: 平文送信している
83+
created_at: new Date().toISOString(),
84+
};
85+
86+
const result = await registerUser(userData);
87+
88+
if (result instanceof ApiError) {
89+
if (result.message) {
90+
setApiError(result.message);
91+
} else {
92+
setApiError("登録中にエラーが発生しました");
93+
}
94+
setIsLoading(false);
95+
return;
96+
}
97+
98+
setSuccessMessage("登録が完了しました!");
99+
setTimeout(() => {
100+
router.push("/");
101+
}, 1500);
102+
} catch (error) {
103+
console.error("登録エラー:", error);
104+
setApiError("登録中にエラーが発生しました");
105+
} finally {
106+
setIsLoading(false);
107+
}
108+
};
109+
110+
const handleChange = (
111+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
112+
) => {
113+
const { name, value, type } = e.target;
114+
const checked = (e.target as HTMLInputElement).checked;
115+
116+
setFormData((prev) => ({
117+
...prev,
118+
[name]: type === "checkbox" ? checked : value,
119+
}));
120+
};
121+
122+
return (
123+
<div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md">
124+
<h1 className="text-2xl font-bold mb-6 text-center">ユーザー登録</h1>
125+
126+
<div className="mb-6 p-4 bg-red-50 border-2 border-red-400 rounded-lg">
127+
<p className="text-red-700 font-bold text-center">⚠️ 注意 ⚠️</p>
128+
<p className="text-red-600 text-sm mt-2">
129+
このサイトは開発中です。実際に使用しているメールアドレスやパスワードは入力しないでください。
130+
</p>
131+
</div>
132+
133+
<form onSubmit={handleSubmit} className="space-y-4">
134+
<div>
135+
<label htmlFor="name" className="block text-sm font-medium mb-1">
136+
ユーザー名 <span className="text-red-500">*</span>
137+
</label>
138+
<input
139+
type="text"
140+
id={nameId}
141+
name="name"
142+
value={formData.name}
143+
onChange={handleChange}
144+
className={`w-full px-3 py-2 border rounded-md ${
145+
errors.name ? "border-red-500" : "border-gray-300"
146+
}`}
147+
placeholder="例: minweb123"
148+
/>
149+
<p className="text-xs text-gray-500 mt-1">
150+
英数字のみ使用可能。後から変更できません。
151+
</p>
152+
{errors.name && (
153+
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
154+
)}
155+
</div>
156+
157+
<div>
158+
<label htmlFor="display_name" className="block text-sm font-medium mb-1">
159+
表示名 <span className="text-red-500">*</span>
160+
</label>
161+
<input
162+
type="text"
163+
id={display_nameId}
164+
name="display_name"
165+
value={formData.display_name}
166+
onChange={handleChange}
167+
className={`w-full px-3 py-2 border rounded-md ${
168+
errors.display_name ? "border-red-500" : "border-gray-300"
169+
}`}
170+
placeholder="例: ミン・ウェブ"
171+
/>
172+
<p className="text-xs text-gray-500 mt-1">絵文字も使用できます (今の所、表示名はどこにも表示されません(?!))</p>
173+
{errors.display_name && (
174+
<p className="text-red-500 text-sm mt-1">{errors.display_name}</p>
175+
)}
176+
</div>
177+
178+
<div>
179+
<label htmlFor="intro" className="block text-sm font-medium mb-1">
180+
自己紹介
181+
</label>
182+
<textarea
183+
id={introId}
184+
name="intro"
185+
value={formData.intro}
186+
onChange={handleChange}
187+
rows={3}
188+
className="w-full px-3 py-2 border border-gray-300 rounded-md"
189+
placeholder="そのうちプロフィールに表示されます"
190+
/>
191+
</div>
192+
193+
<div>
194+
<label htmlFor="email" className="block text-sm font-medium mb-1">
195+
メールアドレス <span className="text-red-500">*</span>
196+
</label>
197+
<input
198+
type="email"
199+
id={emailId}
200+
name="email"
201+
value={formData.email}
202+
onChange={handleChange}
203+
className={`w-full px-3 py-2 border rounded-md ${
204+
errors.email ? "border-red-500" : "border-gray-300"
205+
}`}
206+
placeholder="test@minweb.com"
207+
/>
208+
<p className="text-xs text-gray-500 mt-1">
209+
テスト用のメールアドレスを使用してください
210+
</p>
211+
{errors.email && (
212+
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
213+
)}
214+
</div>
215+
216+
<div className="flex items-center">
217+
<input
218+
type="checkbox"
219+
id={show_emailId}
220+
name="show_email"
221+
checked={formData.show_email}
222+
onChange={handleChange}
223+
className="mr-2"
224+
/>
225+
<label htmlFor="show_email" className="text-sm">
226+
メールアドレスを公開する
227+
</label>
228+
</div>
229+
230+
<div>
231+
<label htmlFor="password" className="block text-sm font-medium mb-1">
232+
パスワード <span className="text-red-500">*</span>
233+
</label>
234+
<input
235+
type="password"
236+
id={passwordId}
237+
name="password"
238+
value={formData.password}
239+
onChange={handleChange}
240+
className={`w-full px-3 py-2 border rounded-md ${
241+
errors.password ? "border-red-500" : "border-gray-300"
242+
}`}
243+
placeholder="6文字以上"
244+
/>
245+
<p className="text-xs text-gray-500 mt-1">
246+
テスト用のパスワードを使用してください
247+
</p>
248+
{errors.password && (
249+
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
250+
)}
251+
</div>
252+
253+
<div>
254+
<label
255+
htmlFor="confirmPassword"
256+
className="block text-sm font-medium mb-1"
257+
>
258+
パスワード(確認) <span className="text-red-500">*</span>
259+
</label>
260+
<input
261+
type="password"
262+
id={confirmPasswordId}
263+
name="confirmPassword"
264+
value={formData.confirmPassword}
265+
onChange={handleChange}
266+
className={`w-full px-3 py-2 border rounded-md ${
267+
errors.confirmPassword ? "border-red-500" : "border-gray-300"
268+
}`}
269+
placeholder="パスワードを再入力"
270+
/>
271+
{errors.confirmPassword && (
272+
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
273+
)}
274+
</div>
275+
276+
{apiError && (
277+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
278+
{apiError}
279+
</div>
280+
)}
281+
282+
{successMessage && (
283+
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
284+
{successMessage}
285+
</div>
286+
)}
287+
288+
<button
289+
type="submit"
290+
disabled={isLoading}
291+
className={`w-full py-2 px-4 rounded-md text-white font-medium ${
292+
isLoading
293+
? "bg-gray-400 cursor-not-allowed"
294+
: "bg-blue-500 hover:bg-blue-600"
295+
}`}
296+
>
297+
{isLoading ? "登録中..." : "登録する"}
298+
</button>
299+
</form>
300+
</div>
301+
);
302+
}

src/app/components/header.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ export default function Header({ onMenuClick }: HeaderProps) {
2626
className="flex items-center px-4 py-2 rounded-2xl bg-sky-600 hover:bg-sky-700 transition-colors duration-200">
2727
<span className="text-s font-bold text-white">記事を書く</span>
2828
</Link>
29-
<div className="flex items-center px-4 py-2 rounded-2xl border border-gray-300 hover:bg-gray-200 transition-colors duration-200">
30-
<Link
31-
href="/auth"
32-
className="text-s font-bold text-gray-500">
33-
ログイン
34-
</Link>
35-
</div>
29+
<Link
30+
href="/auth"
31+
className="flex items-center px-4 py-2 rounded-2xl border border-gray-300 hover:bg-gray-200 transition-colors duration-200">
32+
<span className="text-s font-bold text-gray-500">ユーザー登録</span>
33+
</Link>
3634
</div>
3735
</div>
3836
</div>

src/lib/api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,38 @@ export async function postArticle(author: string, title: string, content: string
9797
} catch {
9898
return new ApiError(ApiErrorType.FAILED_REQUEST);
9999
}
100+
}
101+
102+
export async function registerUser(userData: {
103+
name: string;
104+
display_name: string;
105+
intro: string;
106+
email: string;
107+
show_email: boolean;
108+
password: string;
109+
created_at: string;
110+
}): Promise<{ success: boolean; message?: string } | ApiError> {
111+
const url = `${API_BASE_URL}/users`;
112+
113+
try {
114+
const res = await fetch(url, {
115+
method: 'POST',
116+
headers: {
117+
'Content-Type': 'application/json'
118+
},
119+
body: JSON.stringify(userData)
120+
});
121+
122+
if (!res.ok) {
123+
if (res.status === 500) {
124+
return new ApiError(ApiErrorType.FAILED_REQUEST, "既に登録されているユーザー名です");
125+
}
126+
return new ApiError(ApiErrorType.FAILED_REQUEST, "登録に失敗しました");
127+
}
128+
129+
const data = await res.json();
130+
return { success: true, message: data.message };
131+
} catch {
132+
return new ApiError(ApiErrorType.FAILED_REQUEST);
133+
}
100134
}

0 commit comments

Comments
 (0)