Skip to content

Commit 5a999ec

Browse files
authored
Merge pull request #62 from wafflestudio/haram
refactor: move onboarding preference APIs into UserPreferenceContext
2 parents 3e910d2 + f8273d6 commit 5a999ec

14 files changed

Lines changed: 240 additions & 217 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.env
2+
.codex
23

34
# Logs
45
logs

src/App.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuthProvider } from "@contexts/AuthProvider";
22
import { EventProvider } from "@contexts/EventContext";
33
import { FilterContextProvider } from "@contexts/FilterContext";
44
import { UserDataProvider } from "@contexts/UserDataContext";
5+
import { UserPreferenceProvider } from "@contexts/UserPreferenceContext";
56
import { DayViewContextProvider } from "@contexts/DayViewContext";
67
import { DetailContextProvider } from "./contexts/DetailContext";
78

@@ -14,17 +15,19 @@ function App() {
1415
<AuthProvider>
1516
<EventProvider>
1617
<UserDataProvider>
17-
<FilterContextProvider>
18-
<SearchProvider>
19-
<DayViewContextProvider>
20-
<DetailContextProvider>
21-
<TimetableProvider>
22-
<AppRoutes />
23-
</TimetableProvider>
24-
</DetailContextProvider>
25-
</DayViewContextProvider>
26-
</SearchProvider>
27-
</FilterContextProvider>
18+
<UserPreferenceProvider>
19+
<FilterContextProvider>
20+
<SearchProvider>
21+
<DayViewContextProvider>
22+
<DetailContextProvider>
23+
<TimetableProvider>
24+
<AppRoutes />
25+
</TimetableProvider>
26+
</DetailContextProvider>
27+
</DayViewContextProvider>
28+
</SearchProvider>
29+
</FilterContextProvider>
30+
</UserPreferenceProvider>
2831
</UserDataProvider>
2932
</EventProvider>
3033
</AuthProvider>

src/contexts/AuthProvider.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface AuthContextType {
1515
isLoading: boolean;
1616
login: (email: string, password: string) => Promise<void>;
1717
signup: (email: string, password: string) => Promise<void>;
18+
completeSocialLogin: (accessToken: string) => Promise<void>;
1819
socialLogin: (
1920
provider: Provider,
2021
code: string,
@@ -93,6 +94,19 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
9394
}
9495
};
9596

97+
const completeSocialLogin = async (accessToken: string) => {
98+
try {
99+
TokenService.setToken(accessToken);
100+
const userData = await auth.getUser();
101+
setUser(userData);
102+
setIsAuthenticated(true);
103+
} catch (err) {
104+
TokenService.clearTokens();
105+
console.error("Completing social login failed:", err);
106+
throw err;
107+
}
108+
};
109+
96110
/**
97111
* Signup Function
98112
*/
@@ -183,6 +197,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
183197
isLoading,
184198
login,
185199
signup,
200+
completeSocialLogin,
186201
socialLogin,
187202
logout,
188203
updateUsername,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
createContext,
3+
type ReactNode,
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useState,
8+
} from "react";
9+
import { getCategoryGroups, getOrganizations } from "@api/event";
10+
import { addInterestCategories } from "@api/user";
11+
import type { Category } from "@types";
12+
import { useUserData } from "./UserDataContext";
13+
14+
interface UserPreferenceContextType {
15+
programTypes: Category[];
16+
organizations: Category[];
17+
isLoading: boolean;
18+
error: string | null;
19+
refreshPreferenceOptions: () => Promise<void>;
20+
saveInterestPreferences: (categories: Category[]) => Promise<void>;
21+
}
22+
23+
const UserPreferenceContext = createContext<
24+
UserPreferenceContextType | undefined
25+
>(undefined);
26+
27+
export const UserPreferenceProvider = ({
28+
children,
29+
}: {
30+
children: ReactNode;
31+
}) => {
32+
const { refreshUserData } = useUserData();
33+
const [programTypes, setProgramTypes] = useState<Category[]>([]);
34+
const [organizations, setOrganizations] = useState<Category[]>([]);
35+
const [isLoading, setIsLoading] = useState(false);
36+
const [error, setError] = useState<string | null>(null);
37+
38+
const refreshPreferenceOptions = useCallback(async () => {
39+
setIsLoading(true);
40+
setError(null);
41+
42+
try {
43+
const [categoryGroups, orgs] = await Promise.all([
44+
getCategoryGroups(),
45+
getOrganizations(),
46+
]);
47+
const safeGroups = Array.isArray(categoryGroups) ? categoryGroups : [];
48+
const safeOrganizations = Array.isArray(orgs) ? orgs : [];
49+
50+
const nextProgramTypes = safeGroups
51+
.flatMap((item) => item.categories ?? [])
52+
.filter((category) => category.groupId === 3);
53+
54+
setProgramTypes(nextProgramTypes);
55+
setOrganizations(safeOrganizations);
56+
} catch (fetchError) {
57+
console.error("Failed to load user preference options", fetchError);
58+
setError("관심사 옵션을 불러오는 데 실패했습니다.");
59+
} finally {
60+
setIsLoading(false);
61+
}
62+
}, []);
63+
64+
useEffect(() => {
65+
refreshPreferenceOptions();
66+
}, [refreshPreferenceOptions]);
67+
68+
const saveInterestPreferences = useCallback(
69+
async (categories: Category[]) => {
70+
const items = categories.map((category, index) => ({
71+
categoryId: category.id,
72+
priority: index + 1,
73+
}));
74+
75+
await addInterestCategories(items);
76+
await refreshUserData();
77+
},
78+
[refreshUserData],
79+
);
80+
81+
return (
82+
<UserPreferenceContext.Provider
83+
value={{
84+
programTypes,
85+
organizations,
86+
isLoading,
87+
error,
88+
refreshPreferenceOptions,
89+
saveInterestPreferences,
90+
}}
91+
>
92+
{children}
93+
</UserPreferenceContext.Provider>
94+
);
95+
};
96+
97+
export const useUserPreferences = () => {
98+
const context = useContext(UserPreferenceContext);
99+
100+
if (!context) {
101+
throw new Error(
102+
"useUserPreferences must be used within a UserPreferenceProvider",
103+
);
104+
}
105+
106+
return context;
107+
};

src/pages/auth/Home.tsx

Lines changed: 11 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -3,101 +3,22 @@ import { useNavigate } from "react-router-dom";
33
import logo from "/assets/logo.png";
44
import styles from "@styles/Home.module.css";
55

6-
const {
7-
VITE_KAKAO_REST_API_KEY,
8-
VITE_KAKAO_REDIRECT_URI,
9-
VITE_GOOGLE_CLIENT_ID,
10-
VITE_GOOGLE_REDIRECT_URI,
11-
VITE_NAVER_CLIENT_ID,
12-
VITE_NAVER_REDIRECT_URI,
13-
} = import.meta.env;
6+
const API_URL = import.meta.env.VITE_API_URL || "";
147

15-
/** base64url(+) */
16-
function base64UrlEncode(bytes: ArrayBuffer) {
17-
const uint8 = new Uint8Array(bytes);
18-
let bin = "";
19-
for (const b of uint8) bin += String.fromCharCode(b);
20-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
21-
}
22-
23-
/** sha256 */
24-
async function sha256(text: string) {
25-
const data = new TextEncoder().encode(text);
26-
return crypto.subtle.digest("SHA-256", data);
27-
}
28-
29-
/** PKCE code_verifier (43~128 chars 권장) */
30-
function makeCodeVerifier(len = 64) {
31-
const chars =
32-
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
33-
const bytes = crypto.getRandomValues(new Uint8Array(len));
34-
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
35-
}
8+
const SOCIAL_LOGIN_ENTRY = {
9+
google: `${API_URL}/auth/login/google`,
10+
kakao: `${API_URL}/auth/login/kakao`,
11+
naver: `${API_URL}/auth/login/naver`,
12+
} as const;
3613

3714
export default function Home() {
3815
const navigate = useNavigate();
3916

4017
const toLogin = () => navigate("/auth/login");
4118
const toSignUp = () => navigate("/auth/signup");
4219

43-
// ✅ Kakao: state 생성/저장 + URLSearchParams
44-
const handleKakaoLogin = () => {
45-
const state = crypto.randomUUID();
46-
sessionStorage.setItem("kakao_oauth_state", state);
47-
48-
const url = new URL("https://kauth.kakao.com/oauth/authorize");
49-
url.search = new URLSearchParams({
50-
client_id: VITE_KAKAO_REST_API_KEY,
51-
redirect_uri: VITE_KAKAO_REDIRECT_URI,
52-
response_type: "code",
53-
state,
54-
}).toString();
55-
56-
window.location.href = url.toString();
57-
};
58-
59-
// ✅ Google: PKCE + state 생성/저장 + URLSearchParams
60-
const handleGoogleLogin = async () => {
61-
const state = crypto.randomUUID();
62-
const codeVerifier = makeCodeVerifier(64);
63-
const digest = await sha256(codeVerifier);
64-
const codeChallenge = base64UrlEncode(digest);
65-
66-
sessionStorage.setItem("google_oauth_state", state);
67-
sessionStorage.setItem("google_code_verifier", codeVerifier);
68-
69-
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
70-
url.search = new URLSearchParams({
71-
client_id: VITE_GOOGLE_CLIENT_ID,
72-
redirect_uri: VITE_GOOGLE_REDIRECT_URI,
73-
response_type: "code",
74-
scope: "openid email profile",
75-
include_granted_scopes: "true",
76-
state,
77-
code_challenge: codeChallenge,
78-
code_challenge_method: "S256",
79-
// 필요하면 추가:
80-
// access_type: "offline",
81-
// prompt: "consent",
82-
}).toString();
83-
84-
window.location.href = url.toString();
85-
};
86-
87-
// ✅ Naver: 클릭 시점에서 state 생성/저장
88-
const handleNaverLogin = () => {
89-
const state = crypto.randomUUID();
90-
sessionStorage.setItem("naver_oauth_state", state);
91-
92-
const url = new URL("https://nid.naver.com/oauth2.0/authorize");
93-
url.search = new URLSearchParams({
94-
response_type: "code",
95-
client_id: VITE_NAVER_CLIENT_ID,
96-
redirect_uri: VITE_NAVER_REDIRECT_URI,
97-
state,
98-
}).toString();
99-
100-
window.location.href = url.toString();
20+
const moveToSocialLogin = (provider: keyof typeof SOCIAL_LOGIN_ENTRY) => {
21+
window.location.href = SOCIAL_LOGIN_ENTRY[provider];
10122
};
10223

10324
return (
@@ -117,7 +38,7 @@ export default function Home() {
11738
className={`${styles.btn} ${styles.social}`}
11839
data-provider="google"
11940
type="button"
120-
onClick={handleGoogleLogin}
41+
onClick={() => moveToSocialLogin("google")}
12142
>
12243
<span>구글 계정으로 계속하기</span>
12344
</button>
@@ -126,7 +47,7 @@ export default function Home() {
12647
className={`${styles.btn} ${styles.social}`}
12748
data-provider="kakao"
12849
type="button"
129-
onClick={handleKakaoLogin}
50+
onClick={() => moveToSocialLogin("kakao")}
13051
>
13152
<span>카카오톡 계정으로 계속하기</span>
13253
</button>
@@ -135,7 +56,7 @@ export default function Home() {
13556
className={`${styles.btn} ${styles.social}`}
13657
data-provider="naver"
13758
type="button"
138-
onClick={handleNaverLogin}
59+
onClick={() => moveToSocialLogin("naver")}
13960
>
14061
<span>네이버 계정으로 계속하기</span>
14162
</button>

0 commit comments

Comments
 (0)