Skip to content

Commit 58e5306

Browse files
Merge pull request #19 from NYCU-SDC/feat/CORE-177-user-onboarding-api-integration
[CORE-177] User Onboarding API Integration
2 parents e512fba + f1fca42 commit 58e5306

File tree

6 files changed

+158
-20
lines changed

6 files changed

+158
-20
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@icons-pack/react-simple-icons": "^13.8.0",
22-
"@nycu-sdc/core-system-sdk": "^0.1.5",
22+
"@nycu-sdc/core-system-sdk": "0.1.4-snapshot-e044505",
2323
"@radix-ui/react-checkbox": "^1.3.3",
2424
"@radix-ui/react-dialog": "^1.1.15",
2525
"@radix-ui/react-dropdown-menu": "^2.1.16",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/features/auth/components/NameMarquee.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
inset: -8vh -8vw;
44
overflow: hidden;
55
pointer-events: none;
6+
z-index: -10;
67
}
78

89
.row {

src/features/auth/components/WelcomePage.module.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
gap: 2rem;
77
background: var(--background-color-secondary);
88
padding: 4rem;
9+
width: min(37.5rem, 92vw);
10+
min-width: 320px;
911
clip-path: polygon(2rem 0, 100% 0, 100% calc(100% - 2rem), calc(100% - 2rem) 100%, 0 100%, 0 2rem);
1012
}
1113

@@ -25,3 +27,13 @@
2527
display: flex;
2628
justify-content: center;
2729
}
30+
31+
.inputField input {
32+
background-color: var(--selection);
33+
}
34+
35+
.inputField label::before {
36+
content: "*";
37+
color: var(--red);
38+
margin-right: 0.25rem;
39+
}

src/features/auth/components/WelcomePage.tsx

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,135 @@
1+
import { authService } from "@/features/auth/services/authService";
12
import { UserLayout } from "@/layouts";
23
import { Button, Input } from "@/shared/components";
34
import { ArrowRight } from "lucide-react";
4-
import { useState } from "react";
5+
import { useEffect, useMemo, useState } from "react";
56
import { useNavigate } from "react-router-dom";
67
import { NameMarquee } from "./NameMarquee";
78
import styles from "./WelcomePage.module.css";
89

910
export const WelcomePage = () => {
1011
const navigate = useNavigate();
11-
const [name, setName] = useState("");
12-
const [nickName, setNickName] = useState("Andrew");
12+
const isWelcomeGuardMuted = import.meta.env.VITE_MUTE_WELCOME_GUARD === "true"; // true = allow everyone in, false = protect this page.
13+
const [nickname, setNickname] = useState("");
14+
const [username, setUsername] = useState("");
15+
const [isUsernameFocused, setIsUsernameFocused] = useState(false);
16+
const [isLoading, setIsLoading] = useState(true);
17+
const [isSubmitting, setIsSubmitting] = useState(false);
18+
const [submitError, setSubmitError] = useState("");
1319

14-
const handleSubmit = (e: React.FormEvent) => {
20+
useEffect(() => {
21+
let isMounted = true;
22+
23+
const loadUser = async () => {
24+
try {
25+
const user = await authService.getCurrentUser();
26+
if (!isMounted || !user) return;
27+
28+
const hasGuardFlags = typeof user.isMember === "boolean" && typeof user.isFirstLogin === "boolean";
29+
const isMember = user.isMember === true;
30+
const isFirstLogin = user.isFirstLogin === true;
31+
// TODO: Align with backend contract and switch to fail-close once isMember/isFirstLogin are guaranteed.
32+
if (!isWelcomeGuardMuted && hasGuardFlags && (!isMember || !isFirstLogin)) {
33+
navigate("/", { replace: true });
34+
return;
35+
}
36+
37+
setNickname(typeof user.name === "string" ? user.name : "");
38+
setUsername(typeof user.username === "string" ? user.username : "");
39+
} catch {
40+
if (!isWelcomeGuardMuted && isMounted) {
41+
navigate("/", { replace: true });
42+
}
43+
} finally {
44+
if (isMounted) setIsLoading(false);
45+
}
46+
};
47+
48+
loadUser();
49+
return () => {
50+
isMounted = false;
51+
};
52+
}, [isWelcomeGuardMuted, navigate]);
53+
54+
const nicknameError = useMemo(() => {
55+
if (!nickname) return "";
56+
if (nickname.length > 15) return "暱稱需為 15 字以下";
57+
if (nickname.length === 15) return "已達上限 15 字!";
58+
return "";
59+
}, [nickname]);
60+
61+
const usernameError = useMemo(() => {
62+
if (!username.trim()) return "請輸入使用者名稱";
63+
if (username.length < 4 || username.length > 15) return "使用者名稱需為 4~15 位";
64+
if (!/^[A-Za-z0-9_]+$/.test(username)) return "使用者名稱只能包含英數與底線";
65+
return "";
66+
}, [username]);
67+
68+
const canSubmit = Boolean(nickname.trim() && nickname.trim().length <= 15 && username.trim() && !usernameError && !isSubmitting);
69+
const displayedUsernameError = isUsernameFocused || username.trim() ? usernameError : "";
70+
71+
const handleSubmit = async (e: React.FormEvent) => {
1572
e.preventDefault();
16-
if (name.trim()) {
17-
console.log("Saving name:", name);
73+
if (!canSubmit) return;
74+
75+
try {
76+
setSubmitError("");
77+
setIsSubmitting(true);
78+
79+
await authService.updateOnboarding({
80+
username: username.trim(),
81+
name: nickname.trim()
82+
});
83+
1884
navigate("/forms");
85+
} catch {
86+
setSubmitError("儲存失敗,請稍後再試");
87+
} finally {
88+
setIsSubmitting(false);
1989
}
2090
};
21-
const displayName = `${nickName.trim() || "Andrew"}!`;
91+
92+
const displayName = `${nickname.trim() || "Welcome"}!`;
2293

2394
return (
2495
<UserLayout>
2596
<NameMarquee name={displayName} />
2697
<div className={styles.content}>
27-
<h1 className={styles.title}>很高興認識您,Andrew!</h1>
98+
<h1 className={styles.title}>很高興認識您,{displayName}</h1>
2899
<form className={styles.form} onSubmit={handleSubmit}>
29-
<Input id="nickName" label="暱稱" placeholder="Enter your nickname" value={nickName} onChange={e => setNickName(e.target.value)} required />
30-
<Input id="name" label="使用者名稱" placeholder="Enter your name" value={name} onChange={e => setName(e.target.value)} required />
100+
<Input
101+
id="nickname"
102+
label="暱稱"
103+
placeholder="Enter your nickname"
104+
value={nickname}
105+
onChange={e => setNickname(e.target.value)}
106+
error={nicknameError}
107+
disabled={isLoading}
108+
maxLength={15}
109+
className={styles.inputField}
110+
required
111+
/>
112+
<Input
113+
id="username"
114+
label="使用者名稱"
115+
placeholder="Becomes your user URL"
116+
value={username}
117+
onChange={e => setUsername(e.target.value.replace(/\s/g, ""))}
118+
onFocus={() => setIsUsernameFocused(true)}
119+
onBlur={() => setIsUsernameFocused(false)}
120+
error={displayedUsernameError}
121+
disabled={isLoading}
122+
minLength={4}
123+
maxLength={15}
124+
className={styles.inputField}
125+
required
126+
/>
31127
<div className={styles.actions}>
32-
<Button type="submit" icon={ArrowRight} disabled={!name.trim()} iconPosition="right">
128+
<Button type="submit" icon={ArrowRight} disabled={!canSubmit} iconPosition="right">
33129
繼續
34130
</Button>
35131
</div>
132+
{submitError && <p>{submitError}</p>}
36133
</form>
37134
</div>
38135
</UserLayout>

src/features/auth/services/authService.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,42 @@ export const authService = {
2222
async logout() {
2323
// Replace with actual API call
2424
const response = await fetch("/api/auth/logout", {
25-
method: "POST"
25+
method: "POST",
26+
credentials: "include"
2627
});
2728
return response.json();
2829
},
2930

3031
async getCurrentUser() {
31-
// Replace with actual API call
32-
const response = await fetch("/api/auth/me");
32+
const response = await fetch("/api/users/me", {
33+
credentials: "include"
34+
});
35+
if (!response.ok) {
36+
throw new Error("Failed to get current user");
37+
}
38+
39+
return response.json();
40+
},
41+
42+
async updateOnboarding(payload: { username: string; name: string }) {
43+
const response = await fetch("/api/users/onboarding", {
44+
method: "PUT",
45+
credentials: "include",
46+
headers: {
47+
"Content-Type": "application/json"
48+
},
49+
body: JSON.stringify(payload)
50+
});
51+
52+
if (!response.ok) {
53+
throw new Error("Failed to update onboarding");
54+
}
55+
56+
const contentType = response.headers.get("content-type") ?? "";
57+
if (!contentType.includes("application/json")) {
58+
return null;
59+
}
60+
3361
return response.json();
3462
}
3563
};

0 commit comments

Comments
 (0)