diff --git a/src/context/authProvider.tsx b/src/context/authProvider.tsx index 06c3436..5626f7d 100644 --- a/src/context/authProvider.tsx +++ b/src/context/authProvider.tsx @@ -1,186 +1,159 @@ -// 목적: 앱 전역에서 로그인/유저 상태를 관리하고, 인증 관련 표준 함수를 제공한다. -// 특징: 토큰과 사용자 ID는 내부에서만 관리하고, 외부는 파생 상태와 동작 함수만 사용한다. - +// 원칙: state는 user 하나만 관리한다(부트스트랩/로그인여부는 파생). import { apiLogin, apiSignup } from '@/api/auth'; import { apiGetUser, apiUpdateUser } from '@/api/users'; import type { LoginRequest, User, UserRequest, UserRole } from '@/types/user'; import { useRouter } from 'next/router'; -import { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { createContext, ReactNode, useCallback, useEffect, useState } from 'react'; type AuthContextValue = { - // 파생 상태 + /** 파생: user가 있으면 true */ isLogin: boolean; - role: UserRole; // 'guest' | 'employee' | 'employer' - // 부트스트랩(초기 복원) 완료 여부 노출 (선택적으로 UI 제어에 사용 가능) + /** 파생: user?.type 또는 'guest' */ + role: UserRole; + /** 파생: user !== undefined (부트스트랩 완료 여부) */ bootstrapped: boolean; - // 데이터 + /** 로그인 유저. 미로그인은 null */ user: User | null; - // 동작 + + /** 로그인: 토큰/아이디/만료시각 저장 → 내 정보 조회 → user 채움 */ login: (credentials: LoginRequest) => Promise; - // redirectTo: string | false - // - string: 해당 경로로 replace 이동 - // - false : 이동하지 않음 + /** 로그아웃: 저장소 초기화 + user=null + (옵션) 리다이렉트 */ logout: (redirectTo?: string | false) => void; + /** 회원가입 */ signup: (data: UserRequest) => Promise; + /** 내 정보 재조회: 저장소 userId 기준 */ getUser: () => Promise; + /** 내 정보 수정: 성공 시 Context의 user 동기화 */ updateUser: (patch: Partial) => Promise; }; export const AuthContext = createContext(null); -// 로컬 스토리지 키 (고정) +/** LocalStorage keys */ const TOKEN_KEY = 'thejulge_token'; const USER_ID_KEY = 'thejulge_user_id'; -const EXPIRES_KEY = 'thejulge_expires_at'; // 만료시간 저장 키 -const EXP_TIME = 1000 * 60 * 1000; // 만료 유지시간 10분 +const EXPIRES_KEY = 'thejulge_expires_at'; +const EXPIRES_DURATION_MS = 10 * 60 * 1000; // 10분 + +/** storage helpers (이름 풀기) */ +const isBrowser = () => typeof window !== 'undefined'; -// 브라우저에서만 동작하도록 가드된 유틸 -const setStorage = (key: string, value: string) => { - if (typeof window !== 'undefined') localStorage.setItem(key, value); +const setLocalStorageItem = (key: string, value: string) => { + if (isBrowser()) localStorage.setItem(key, value); }; -const getStorage = (key: string) => - typeof window !== 'undefined' ? localStorage.getItem(key) : null; -const removeStorage = (key: string) => { - if (typeof window !== 'undefined') localStorage.removeItem(key); +const getLocalStorageItem = (key: string) => (isBrowser() ? localStorage.getItem(key) : null); +const removeLocalStorageItem = (key: string) => { + if (isBrowser()) localStorage.removeItem(key); +}; + +const readAuthFromStorage = () => { + const token = getLocalStorageItem(TOKEN_KEY); + const userId = getLocalStorageItem(USER_ID_KEY); + const expiresAt = Number(getLocalStorageItem(EXPIRES_KEY) ?? '') || 0; + return { token, userId, expiresAt }; }; const AuthProvider = ({ children }: { children: ReactNode }) => { const router = useRouter(); - // 핵심 상태: 토큰, 사용자 ID, 사용자 정보 - const [token, setToken] = useState(null); - const [userId, setUserId] = useState(null); - const [user, setUser] = useState(null); - - // 변경: 부트스트랩(초기 세션 복원) 완료 플래그 - const [bootstrapped, setBootstrapped] = useState(false); + const [user, setUser] = useState(undefined); - // 파생 상태 - // 변경: isLogin = 토큰 + 유저가 모두 있어야 true (과도기에 guest+로그아웃 동시 노출 방지) - const isLogin = !!token && !!user; - const role: UserRole = useMemo(() => (user ? user.type : 'guest'), [user]); + /** 파생값 */ + const isLogin = !!user; + const role: UserRole = user ? user.type : 'guest'; + const bootstrapped = user !== undefined; + /** 로그아웃: 저장소 초기화 + user=null + (옵션) 리다이렉트 */ const logout = useCallback( (redirectTo: string | false = '/') => { - setToken(null); setUser(null); - setUserId(null); - removeStorage(TOKEN_KEY); - removeStorage(USER_ID_KEY); - removeStorage(EXPIRES_KEY); // 만료키 삭제 - // 로그아웃 후 이동 (replace: 뒤로가기 눌러도 다시 로그인 상태로 못 돌아가게) - if (redirectTo !== false) { - router.replace(redirectTo); - } + removeLocalStorageItem(TOKEN_KEY); + removeLocalStorageItem(USER_ID_KEY); + removeLocalStorageItem(EXPIRES_KEY); + if (redirectTo !== false) router.replace(redirectTo); }, [router] ); - // 앱 시작 시 저장소에서 복원 + /** 부트스트랩: 저장소 값 유효 → /users/{id} 조회 → user 주입 (아니면 user=null) */ useEffect(() => { - let cancelled = false; // 변경: 언마운트 가드 - - (async () => { - const storedToken = getStorage(TOKEN_KEY); - const storedUserId = getStorage(USER_ID_KEY); - const expText = getStorage(EXPIRES_KEY) ?? ''; // exp: 만료시각(ms) 문자열 - const exp = Number(expText) || 0; // 문자열 → 숫자 (없으면 0) - - // 토큰/ID 없거나, exp 없거나, 이미 지났으면 즉시 로그아웃 - if (!storedToken || !storedUserId || !exp || Date.now() >= exp) { - logout(false); - setBootstrapped(true); // 복원 종료 신호 + const bootstrap = async () => { + const { token, userId, expiresAt } = readAuthFromStorage(); + const isInvalid = !token || !userId || !expiresAt || Date.now() >= expiresAt; + if (isInvalid) { + logout(false); // 이동은 하지 않음 + setUser(null); // 부트스트랩 종료(비로그인) return; } - - // 유효할 때만 복원 + user까지 동기화(여기 전까지는 isLogin=false) - setToken(storedToken); - setUserId(storedUserId); - try { - const me = await apiGetUser(storedUserId); - if (!cancelled) setUser(me); + const me = await apiGetUser(userId); + setUser(me); } catch { - logout(); - } finally { - if (!cancelled) setBootstrapped(true); // 복원 종료 신호 + logout(false); + setUser(null); } - })(); - - return () => { - cancelled = true; }; + bootstrap(); }, [logout]); - // 로그인: /token → 토큰/사용자 ID 저장 → /users/{id}로 내 정보 동기화 + /** 로그인: 토큰/아이디/만료시각 저장 → 내 정보 조회 → user 채움 */ const login = useCallback(async (credentials: LoginRequest) => { const res = await apiLogin(credentials); - const newToken = res.item.token; - const newUserId = res.item.user.item.id; - - const exp = Date.now() + EXP_TIME; // 지금부터 10분 후 만료시각 계산 - setStorage(EXPIRES_KEY, String(exp)); // 만료시각 저장 + const token = res.item.token; + const userId = res.item.user.item.id; + const expiresAt = Date.now() + EXPIRES_DURATION_MS; - setToken(newToken); - setUserId(newUserId); - setStorage(TOKEN_KEY, newToken); - setStorage(USER_ID_KEY, newUserId); + setLocalStorageItem(TOKEN_KEY, token); + setLocalStorageItem(USER_ID_KEY, userId); + setLocalStorageItem(EXPIRES_KEY, String(expiresAt)); - // 로그인 직후에도 user를 먼저 채운 뒤에야 isLogin=true가 되도록 - const me = await apiGetUser(newUserId); + const me = await apiGetUser(userId); setUser(me); }, []); - // 회원가입: /users 성공만 확인 (라우팅은 화면에서 처리) + /** 회원가입 */ const signup = useCallback(async (data: UserRequest) => { await apiSignup(data); }, []); - // 내 정보 재조회 + /** 내 정보 재조회: 저장소 userId 기준 */ const getUser = useCallback(async () => { + const { userId } = readAuthFromStorage(); if (!userId) throw new Error('로그인이 필요합니다'); const me = await apiGetUser(userId); setUser(me); - }, [userId]); - - // 내 정보 수정 - const updateUser = useCallback( - async (patch: Partial) => { - if (!userId) throw new Error('로그인이 필요합니다'); - const updated = await apiUpdateUser(userId, patch); - setUser(updated); - }, - [userId] - ); + }, []); + + /** 내 정보 수정: 성공 시 Context의 user 동기화 */ + const updateUser = useCallback(async (patch: Partial) => { + const { userId } = readAuthFromStorage(); + if (!userId) throw new Error('로그인이 필요합니다'); + const updated = await apiUpdateUser(userId, patch); + setUser(updated); + }, []); - // 1분마다 만료여부 확인 + /** 만료 체크: 1분마다 확인 → 만료 시 자동 로그아웃 */ useEffect(() => { - if (!token) return; - const interval = setInterval(() => { - const expText = getStorage(EXPIRES_KEY) ?? ''; // 만료시각 다시 읽기 - const exp = Number(expText) || 0; // 숫자로 변환 - if (!exp || Date.now() >= exp) logout('/'); // 만료면 로그아웃하고 메인으로 + const timerId = setInterval(() => { + const { expiresAt } = readAuthFromStorage(); + if (!expiresAt || Date.now() >= expiresAt) logout('/'); }, 60 * 1000); - return () => clearInterval(interval); - }, [token, logout]); - - // 컨텍스트 값 메모이즈 (리렌더 최소화) - const value = useMemo( - () => ({ - isLogin, - role, - // 부트스트랩 완료 여부도 컨텍스트로 제공 - bootstrapped, - user, - login, - logout, - signup, - getUser, - updateUser, - }), - [isLogin, role, bootstrapped, user, login, logout, signup, getUser, updateUser] - ); + return () => clearInterval(timerId); + }, [logout]); - return {children}; + /** Context 값 */ + const contextValue: AuthContextValue = { + isLogin, + role, + bootstrapped, + user: user ?? null, + login, + logout, + signup, + getUser, + updateUser, + }; + + return {children}; }; export default AuthProvider; diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 6abf14e..5185589 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -6,7 +6,8 @@ import useAuth from '@/hooks/useAuth'; import { cn } from '@/lib/utils/cn'; import Image from 'next/image'; import Link from 'next/link'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; +import type { NextPageWithLayout } from './_app'; const getMsg = (err: unknown, fallback: string) => { if (typeof err === 'string') return err; @@ -20,23 +21,19 @@ const getMsg = (err: unknown, fallback: string) => { return fallback; }; -export default function LoginPage() { +const LoginPage: NextPageWithLayout = () => { const { login } = useAuth(); - // 입력 const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); - // blur 에러 const [emailErr, setEmailErr] = useState(null); const [pwErr, setPwErr] = useState(null); - // 기타 상태 const [loading, setLoading] = useState(false); const [failOpen, setFailOpen] = useState(false); const [globalErr, setGlobalErr] = useState(null); - // 요구사항: blur 시 이메일 형식/비번 길이 체크 const onBlurEmail = (e: React.FocusEvent) => { if (e.currentTarget.validity.typeMismatch) setEmailErr('이메일 형식으로 작성해 주세요.'); else setEmailErr(null); @@ -64,11 +61,9 @@ export default function LoginPage() { setLoading(true); try { await login({ email, password: pw }); - // 로그인 성공 → 공고 리스트로 이동 window.location.href = '/'; - } catch (err: unknown) { + } catch (err) { const status = (err as { response?: { status?: number } })?.response?.status; - // 401/400 등은 모달로 안내 if (status && [400, 401].includes(status)) setFailOpen(true); else setGlobalErr(getMsg(err, '로그인 중 오류가 발생했습니다.')); } finally { @@ -86,7 +81,7 @@ export default function LoginPage() { 'desktop:flex desktop:min-h-[1024px] desktop:flex-col desktop:items-center' )} > - {/* 로고: 공고 목록으로 이동 */} + {/* 로고 */}

@@ -161,7 +156,6 @@ export default function LoginPage() {

)} - {/* 로그인 버튼: desktop 350×48, radius 6, py14 px136 */}
+

+ + )} + - {/* 우상단 편집 버튼 — 169×48 */} -
- + {/* 신청 내역 — 프로필 있을 때만 노출 */} + {!isProfileEmpty && ( +
+ {isLoadingApps ? ( +
불러오는 중…
+ ) : applications.length === 0 ? ( +
+ +
+ ) : ( +
+

신청 내역

+ {/* 팀 Table이 요구하는 pagination props 전달 */} + setOffset(p*limit) 로 바꾸세요. + /> - + )} - - - {/* 신청 내역 — 0건이면 Frame, ≥1건이면 타이틀 + Table / 데스크톱 964px 중앙 */} -
- {isLoadingApps ? ( -
불러오는 중…
- ) : applications.length === 0 ? ( -
- -
- ) : ( -
-

신청 내역

-
- - )} - + )} ); } diff --git a/src/pages/my-profile/register.tsx b/src/pages/my-profile/register.tsx index b101532..3a68053 100644 --- a/src/pages/my-profile/register.tsx +++ b/src/pages/my-profile/register.tsx @@ -1,31 +1,14 @@ -import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; - +import { Icon } from '@/components/ui'; import Button from '@/components/ui/button/button'; import Dropdown from '@/components/ui/dropdown/dropdown'; import Input from '@/components/ui/input/input'; import Modal from '@/components/ui/modal/modal'; import useAuth from '@/hooks/useAuth'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; import { ADDRESS_CODE, type AddressCode } from '@/constants/dropdown'; -/** 스토리지 헬퍼 */ -function makeProfileStorageKey(userId?: string | null) { - return `thejulge_profile_${userId ?? 'guest'}`; -} -function readJsonFromStorage(key: string): T | null { - try { - const text = localStorage.getItem(key); - if (!text) return null; - return JSON.parse(text) as T; - } catch { - return null; - } -} -function writeJsonToStorage(key: string, value: T) { - localStorage.setItem(key, JSON.stringify(value)); -} - /** 폼 타입 */ type ProfileForm = { name: string; @@ -36,9 +19,7 @@ type ProfileForm = { export default function MyProfileRegisterPage() { const router = useRouter(); - const { isLogin, user } = useAuth(); - - const profileStorageKey = useMemo(() => makeProfileStorageKey(user?.id), [user?.id]); + const { isLogin, user, updateUser } = useAuth(); const [formState, setFormState] = useState({ name: '', @@ -52,25 +33,27 @@ export default function MyProfileRegisterPage() { const [regionErrorMessage, setRegionErrorMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [isDoneOpen, setIsDoneOpen] = useState(false); // ✅ 등록 완료 모달 + const [isDoneOpen, setIsDoneOpen] = useState(false); // 완료 모달 + const [isCancelOpen, setIsCancelOpen] = useState(false); // 취소 확인 모달 // 로그인 가드 useEffect(() => { - if (!isLogin) { - router.replace('/login'); - } + if (!isLogin) router.replace('/login'); }, [isLogin, router]); - // 기존 저장값 로드(수정 모드) + // 기존 값 프리필(컨텍스트 user 사용) useEffect(() => { - if (!isLogin) return; - const saved = readJsonFromStorage(profileStorageKey); - if (saved) setFormState(saved); - }, [isLogin, profileStorageKey]); - - const updateFormField = (fieldName: K, value: ProfileForm[K]) => { - setFormState(prev => ({ ...prev, [fieldName]: value })); - }; + if (!isLogin || !user) return; + setFormState({ + name: user.name ?? '', + phone: user.phone ?? '', + region: (user.address as AddressCode) ?? '', + bio: user.bio ?? '', + }); + }, [isLogin, user]); + + const updateFormField = (k: K, v: ProfileForm[K]) => + setFormState(prev => ({ ...prev, [k]: v })); const isFormSubmittable = !!formState.name && @@ -81,7 +64,7 @@ export default function MyProfileRegisterPage() { !regionErrorMessage && !isSubmitting; - const handleSubmit: React.FormEventHandler = e => { + const handleSubmit: React.FormEventHandler = async e => { e.preventDefault(); if (!formState.name.trim()) setNameErrorMessage('이름을 입력해 주세요.'); @@ -93,20 +76,30 @@ export default function MyProfileRegisterPage() { setIsSubmitting(true); try { - writeJsonToStorage(profileStorageKey, { + // 서버 반영 + 컨텍스트 동기화 + await updateUser({ name: formState.name.trim(), phone: formState.phone.trim(), - region: formState.region, + address: formState.region, bio: formState.bio, }); - setIsDoneOpen(true); // ✅ 모달 오픈 + setIsDoneOpen(true); } finally { setIsSubmitting(false); } }; return ( -
+
+ {/* 우상단 닫기(X) 버튼 */} +

내 프로필

+ + {/* 취소 확인 모달 */} + setIsCancelOpen(false)} + title='등록을 취소하시겠습니까?' + description={

작성 중인 내용은 저장되지 않습니다.

} + variant='warning' + secondaryText='아니오' + onSecondary={() => setIsCancelOpen(false)} + primaryText='예' + onPrimary={() => router.replace('/my-profile')} + />
); } diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx index 459f63c..7eae3c8 100644 --- a/src/pages/signup.tsx +++ b/src/pages/signup.tsx @@ -7,11 +7,11 @@ import useAuth from '@/hooks/useAuth'; import { cn } from '@/lib/utils/cn'; import Image from 'next/image'; import Link from 'next/link'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; +import type { NextPageWithLayout } from './_app'; type MemberType = 'employee' | 'employer'; -// 에러 객체를 메시지로 변환 const getMsg = (err: unknown, fallback: string) => { if (typeof err === 'string') return err; if (err && typeof err === 'object') { @@ -24,26 +24,23 @@ const getMsg = (err: unknown, fallback: string) => { return fallback; }; -export default function SignupPage() { +const SignupPage: NextPageWithLayout = () => { const { signup } = useAuth(); - // Inpun 값 const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [pw2, setPw2] = useState(''); - const [type, setType] = useState('employee'); // 기본: 알바님 + const [type, setType] = useState('employee'); - // blur 에러 상태 const [emailErr, setEmailErr] = useState(null); const [pwErr, setPwErr] = useState(null); const [pw2Err, setPw2Err] = useState(null); - // 전역 상태 const [loading, setLoading] = useState(false); - const [dupOpen, setDupOpen] = useState(false); // 409 모달 + const [dupOpen, setDupOpen] = useState(false); + const [successOpen, setSuccessOpen] = useState(false); const [globalErr, setGlobalErr] = useState(null); - // ── blur(=focus out) 유효성 ── const onBlurEmail = (e: React.FocusEvent) => { if (e.currentTarget.validity.typeMismatch) setEmailErr('이메일 형식으로 작성해 주세요.'); else setEmailErr(null); @@ -51,21 +48,17 @@ export default function SignupPage() { const onBlurPw = () => setPwErr(pw.length < 8 ? '8자 이상 입력해주세요.' : null); const onBlurPw2 = () => setPw2Err(pw !== pw2 ? '비밀번호가 일치하지 않습니다.' : null); - // 제출 버튼 활성 조건 const canSubmit = !!email && pw.length >= 8 && !!pw2 && pw === pw2 && !emailErr && !pwErr && !pw2Err && !loading; - // 제출 핸들러 const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setGlobalErr(null); - // 브라우저 기본 검증 if (!e.currentTarget.checkValidity()) { e.currentTarget.reportValidity(); return; } - // 아직 blur 안 한 경우 마지막 점검 if (!canSubmit) { onBlurEmail({ currentTarget: e.currentTarget.email, @@ -78,9 +71,8 @@ export default function SignupPage() { setLoading(true); try { await signup({ email, password: pw, type }); - alert('가입이 완료되었습니다'); - window.location.href = '/login'; - } catch (err: unknown) { + setSuccessOpen(true); // ✅ alert → 모달 + } catch (err) { const status = (err as { response?: { status?: number } })?.response?.status; if (status === 409) setDupOpen(true); else setGlobalErr(getMsg(err, '회원가입 중 오류가 발생했습니다.')); @@ -89,7 +81,6 @@ export default function SignupPage() { } }; - // 회원 유형 function TypePill({ value, label, @@ -110,7 +101,6 @@ export default function SignupPage() { checked ? 'border-[var(--red-500)] bg-[var(--red-100)] text-[var(--red-600)]' : 'border-[var(--gray-300)] bg-white text-[var(--black)]', - // 데스크탑 시안 보정(높이/라운드/간격) 'desktop:h-[44px] desktop:gap-[8px] desktop:rounded-[24px] desktop:px-[20px]', className )} @@ -134,7 +124,6 @@ export default function SignupPage() { /> ) : ( - // 미선택 )} @@ -153,7 +142,7 @@ export default function SignupPage() { 'desktop:flex desktop:min-h-[1024px] desktop:flex-col desktop:items-center' )} > - {/* 로고: 부모가 실제 크기를 결정 */} + {/* 로고 */}

@@ -169,7 +158,7 @@ export default function SignupPage() {

- {/* 폼: desktop 350px / gap 28 */} + {/* 폼 */} - {/* 회원 유형: 2등분(부모 폭=가입버튼 폭) */} + {/* 회원 유형 */}
회원 유형
@@ -258,7 +247,7 @@ export default function SignupPage() {

)} - {/* 가입하기: desktop 350×48, radius 6, py14 px136 */} + {/* 가입하기 */}
- {/* 내부 라우팅 Link 사용 */}

이미 가입하셨나요?{' '} @@ -296,6 +284,25 @@ export default function SignupPage() { primaryText='확인' onPrimary={() => setDupOpen(false)} /> + + {/* 가입 성공 모달 */} + setSuccessOpen(false)} + title='가입이 완료되었습니다' + description={

이메일과 비밀번호로 로그인해 주세요.

} + variant='success' + primaryText='로그인하기' + onPrimary={() => { + setSuccessOpen(false); + window.location.href = '/login'; + }} + />
); -} +}; + +// Header/Footer 제거용 전용 레이아웃 +SignupPage.getLayout = (page: ReactNode) => page; + +export default SignupPage;