diff --git a/src/app/routes/PrivateRoute.tsx b/src/app/routes/PrivateRoute.tsx index b9cac16..b47472e 100644 --- a/src/app/routes/PrivateRoute.tsx +++ b/src/app/routes/PrivateRoute.tsx @@ -9,8 +9,13 @@ const PrivateRoute = ({ children: React.ReactNode; }): JSX.Element => { const user = useAuthStore((state) => state.user); + const isLoading = useAuthStore((state) => state.isLoading); const location = useLocation(); + if (isLoading) { + return
Loading...
; + } + if (!user) { return ; } diff --git a/src/entities/user/api/userApi.ts b/src/entities/user/api/userApi.ts deleted file mode 100644 index 0538fe0..0000000 --- a/src/entities/user/api/userApi.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { doc, setDoc, getDoc } from "firebase/firestore"; - -import { db } from "@shared/firebase/firebase"; -import type { User } from "@shared/types/user"; - -export const saveUser = async (uid: string, userInfo: User): Promise => { - const userDoc = doc(db, "users", uid); - await setDoc(userDoc, userInfo); -}; - -export const getUser = async (uid: string): Promise => { - const userDoc = doc(db, "users", uid); - const userSnap = await getDoc(userDoc); - if (userSnap.exists()) { - return userSnap.data() as User; - } - return null; -}; diff --git a/src/entities/user/hooks/useSignUp.ts b/src/entities/user/hooks/useSignUp.ts index 533673e..b5bf429 100644 --- a/src/entities/user/hooks/useSignUp.ts +++ b/src/entities/user/hooks/useSignUp.ts @@ -1,7 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { saveUser } from "@entities/user/api/userApi"; - +import { saveUser } from "@shared/api/userApi"; import { useAuthStore } from "@shared/stores/authStore"; import type { UserInput } from "@shared/types/user"; diff --git a/src/entities/user/hooks/useUpdateUser.ts b/src/entities/user/hooks/useUpdateUser.ts new file mode 100644 index 0000000..6d6f397 --- /dev/null +++ b/src/entities/user/hooks/useUpdateUser.ts @@ -0,0 +1,29 @@ +import { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; + +import { updateUser } from "@shared/api/userApi"; +import type { User } from "@shared/types/user"; + +export const useUpdateUser = (): UseMutationResult< + void, + Error, + { + uid: string; + userInfo: Partial; + }, + unknown +> => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ uid, userInfo }: { uid: string; userInfo: Partial }) => + updateUser(uid, userInfo), + onSuccess: (_, { uid }) => { + // 유저 프로필 쿼리 무효화하여 최신 데이터로 갱신 + queryClient.invalidateQueries({ queryKey: ["userProfile", uid] }); + }, + }); +}; diff --git a/src/entities/user/hooks/useUpdateUserForm.ts b/src/entities/user/hooks/useUpdateUserForm.ts new file mode 100644 index 0000000..fa7b6be --- /dev/null +++ b/src/entities/user/hooks/useUpdateUserForm.ts @@ -0,0 +1,92 @@ +import { useState } from "react"; + +import type { User, UserInput, UserRole } from "@shared/types/user"; +import { UserExperience } from "@shared/types/user"; + +interface UseUpdateUserFormProps { + defaultUser: User; +} + +interface UseUpdateUserFormReturn { + name: string; + userRole: string; + experience: string; + introduceMyself: string; + errors: { + name: boolean; + userRole: boolean; + experience: boolean; + }; + handleChange: ( + field: "name" | "userRole" | "experience" | "introduceMyself" + ) => (value: string) => void; + handleSubmit: () => UserInput | null; +} + +export function useUpdateUserForm({ + defaultUser, +}: UseUpdateUserFormProps): UseUpdateUserFormReturn { + const [name, setName] = useState(defaultUser.name); + const [userRole, setUserRole] = useState(defaultUser.userRole); + const [experience, setExperience] = useState(defaultUser.experience); + const [introduceMyself, setIntroduceMyself] = useState( + defaultUser.introduceMyself || "" + ); + const [errors, setErrors] = useState({ + name: false, + userRole: false, + experience: false, + }); + + const handleChange = + (field: "name" | "userRole" | "experience" | "introduceMyself") => + (value: string) => { + switch (field) { + case "name": + setName(value); + if (errors.name) setErrors((prev) => ({ ...prev, name: false })); + break; + case "userRole": + setUserRole(value); + if (errors.userRole) + setErrors((prev) => ({ ...prev, userRole: false })); + break; + case "experience": + setExperience(value); + if (errors.experience) + setErrors((prev) => ({ ...prev, experience: false })); + break; + case "introduceMyself": + setIntroduceMyself(value); + break; + } + }; + + const handleSubmit = (): UserInput | null => { + if (name === "" || userRole === "" || experience === "") { + setErrors({ + name: name === "", + userRole: userRole === "", + experience: experience === "", + }); + return null; + } + + return { + name, + userRole: userRole as UserRole, + experience: experience as UserExperience, + introduceMyself: introduceMyself || "", + }; + }; + + return { + name, + userRole, + experience, + introduceMyself, + errors, + handleChange, + handleSubmit, + }; +} diff --git a/src/entities/user/ui/SubmitButton.tsx b/src/entities/user/ui/SubmitButton.tsx index 83ad08e..84f88fd 100644 --- a/src/entities/user/ui/SubmitButton.tsx +++ b/src/entities/user/ui/SubmitButton.tsx @@ -1,8 +1,16 @@ import { Button, styled } from "@mui/material"; import type { JSX } from "react"; -const SubmitButton = ({ onClick }: { onClick: () => void }): JSX.Element => { - return 회원가입 완료; +interface SubmitButtonProps { + onClick: () => void; + text?: string; +} + +const SubmitButton = ({ + onClick, + text = "회원가입 완료", +}: SubmitButtonProps): JSX.Element => { + return {text}; }; export default SubmitButton; diff --git a/src/entities/user/ui/UpdateUserForm.tsx b/src/entities/user/ui/UpdateUserForm.tsx new file mode 100644 index 0000000..d8307c3 --- /dev/null +++ b/src/entities/user/ui/UpdateUserForm.tsx @@ -0,0 +1,253 @@ +import { + Box, + MenuItem, + Select, + TextField, + Typography, + FormControl, + FormHelperText, + InputLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { type JSX } from "react"; +import { useState } from "react"; + +import { useUpdateUserForm } from "@entities/user/hooks/useUpdateUserForm"; + +import type { User, UserInput } from "@shared/types/user"; +import { UserExperience } from "@shared/types/user"; + +interface UpdateUserFormProps { + defaultUser: User; + onSubmit: (userInfo: UserInput) => void; + onCancel: () => void; +} + +const UpdateUserForm = ({ + defaultUser, + onSubmit, + onCancel, +}: UpdateUserFormProps): JSX.Element => { + const { + name, + userRole, + experience, + introduceMyself, + errors, + handleChange, + handleSubmit, + } = useUpdateUserForm({ defaultUser }); + + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + const handleFormSubmit = (): void => { + const result = handleSubmit(); + if (result) { + setShowConfirmDialog(true); + } + }; + + const handleConfirmSubmit = (): void => { + const result = handleSubmit(); + if (result) { + onSubmit(result); + setShowConfirmDialog(false); + } + }; + + const handleCancelConfirm = (): void => { + setShowConfirmDialog(false); + }; + + return ( + <> + + 프로필 수정 + {/* 이름 입력 */} + + handleChange("name")(e.target.value)} + error={errors.name} + onFocus={() => errors.name && handleChange("name")(name)} + InputLabelProps={{ shrink: true }} + /> + {errors.name && 이름을 입력해주세요.} + + {/* 이메일 입력 (disabled) */} + + + + {/* 직무 선택 */} + + 👔 직무 * + handleChange("userRole")(e.target.value as string)} + displayEmpty + > + 직무 선택 + 프론트엔드 + 백엔드 + 풀스택 + 디자이너 + PM + + {errors.userRole && 직무를 선택해주세요.} + + {/* 경력 선택 */} + + 💼 경력 * + + handleChange("experience")(e.target.value as string) + } + displayEmpty + > + 경력 선택 + 주니어 (3년 이하) + + 미들 (3년 이상 10년 이하) + + + 시니어 (10년 이상) + + + {errors.experience && 경력을 선택해주세요.} + + handleChange("introduceMyself")(e.target.value)} + placeholder="코딩하고 싶은 밤이에요~😘" + multiline + rows={4} + InputLabelProps={{ shrink: true }} + /> + + 취소 + + 저장 + + + + + {/* 확인 다이얼로그 */} + + + 저장하시겠습니까? + + + + 입력하신 정보로 프로필이 업데이트됩니다. + + + + + + + + + ); +}; + +export default UpdateUserForm; + +const FormContainer = styled(Box)({ + display: "flex", + flexDirection: "column", + gap: "16px", + width: "100%", + maxWidth: "400px", + margin: "0 auto", + padding: "2rem 1rem", +}); + +const Title = styled(Typography)({ + textAlign: "center", + marginBottom: "16px", +}); + +const StyledTextField = styled(TextField)({ + width: "100%", +}); + +const StyledSelect = styled(Select)({ + width: "100%", +}); + +const ErrorText = styled(FormHelperText)({ + fontSize: "1.3rem", + color: "#f44336", // MUI 기본 error color + marginLeft: "14px", +}); + +const ButtonContainer = styled(Box)({ + display: "flex", + marginTop: "8px", + gap: "2rem", + justifyContent: "space-between", +}); + +const CancelButton = styled("button")({ + flex: 1, + padding: "12px 24px", + border: "1px solid #ccc", + borderRadius: "8px", + backgroundColor: "#fff", + color: "#666", + fontSize: "1.4rem", + fontWeight: 500, + cursor: "pointer", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: "#f5f5f5", + borderColor: "#999", + }, +}); + +const SubmitButtonStyled = styled("button")({ + flex: 1, + padding: "12px 24px", + border: "1px solid #1976d2", + borderRadius: "8px", + backgroundColor: "#1976d2", + color: "#fff", + fontSize: "1.4rem", + fontWeight: 500, + cursor: "pointer", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: "#1565c0", + borderColor: "#1565c0", + }, +}); diff --git a/src/entities/user/ui/user-profile/UserProfileCard.tsx b/src/entities/user/ui/user-profile/UserProfileCard.tsx index a366c09..cdda2b0 100644 --- a/src/entities/user/ui/user-profile/UserProfileCard.tsx +++ b/src/entities/user/ui/user-profile/UserProfileCard.tsx @@ -7,12 +7,21 @@ import { CardContent, IconButton, Divider, + Dialog, + DialogContent, + Tooltip, } from "@mui/material"; import { styled as muiStyled } from "@mui/material/styles"; import type { ComponentType, JSX } from "react"; +import { useState } from "react"; + +import { useUpdateUser } from "@entities/user/hooks/useUpdateUser"; +import UpdateUserForm from "@entities/user/ui/UpdateUserForm"; import { useProjectStore } from "@shared/stores/projectStore"; -import type { User } from "@shared/types/user"; +import type { User, UserInput } from "@shared/types/user"; +import { UserExperience } from "@shared/types/user"; +import SnackbarAlert from "@shared/ui/SnackbarAlert"; import TabWithBadge from "./TapWithBadge"; @@ -33,10 +42,11 @@ const userRoleMap: Record = { designer: "디자이너", pm: "PM", }; + const experienceMap: Record = { - junior: "주니어 (3년 이하) 🌱", - mid: "미들 (3년 이상 10년 이하) 🌿", - senior: "시니어 (10년 이상) 🌳", + [UserExperience.junior]: "주니어 (3년 이하) 🌱", + [UserExperience.mid]: "미들 (3년 이상 10년 이하) 🌿", + [UserExperience.senior]: "시니어 (10년 이상) 🌳", }; const UserProfileCard = ({ @@ -47,56 +57,146 @@ const UserProfileCard = ({ ProfileTabChip, }: UserProfileCardProps): JSX.Element => { const { likeProjects, appliedProjects } = useProjectStore(); + const [openModal, setOpenModal] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const updateUserMutation = useUpdateUser(); + + const handleOpenModal = (): void => { + setOpenModal(true); + }; + + const handleCloseModal = (): void => { + setOpenModal(false); + }; + + const handleSubmitUpdate = async (userInfo: UserInput): Promise => { + try { + await updateUserMutation.mutateAsync({ + uid: userProfile.id, + userInfo, + }); + + setOpenModal(false); + setSnackbarOpen(true); + } catch (error) { + console.error("프로필 업데이트 실패:", error); + // 에러 처리 로직 추가 가능 + } + }; return ( - - - - - - - - - - + <> + + + + + + + + + + + + + + {userProfile.name} + + + {userRoleMap[userProfile.userRole] || userProfile.userRole} + + + {experienceMap[userProfile.experience] || + userProfile.experience} + + + + - {userProfile.name} - - - {userRoleMap[userProfile.userRole] || userProfile.userRole} + {userProfile.introduceMyself} - - {experienceMap[userProfile.experience] || userProfile.experience} - - - - - {userProfile.introduceMyself} - - - - {PROFILE_TABS.map((tabInfo, idx) => ( - setTab(idx)} - ProfileTabChip={ProfileTabChip} - /> - ))} - - 💌 • {userProfile.email} - - + + + + {PROFILE_TABS.map((tabInfo, idx) => ( + setTab(idx)} + ProfileTabChip={ProfileTabChip} + /> + ))} + + 💌 • {userProfile.email} + + + + {/* 프로필 수정 모달 */} + + + + + + + {/* 스낵바 알림 */} + setSnackbarOpen(false)} + message="프로필 정보가 업데이트되었습니다! ✨" + severity="success" + /> + ); }; @@ -105,13 +205,11 @@ export default UserProfileCard; // 스타일 컴포넌트 재사용 const ProfileCard = muiStyled(Card)(({ theme }) => ({ minWidth: 280, - maxWidth: "100%", + maxWidth: 380, borderRadius: 12, boxShadow: theme.shadows[2], position: "relative", padding: "0 2rem", - maxHeight: "350px", - overflow: "auto", })); const ProfileCardContent = muiStyled(CardContent)(({ theme }) => ({ padding: theme.spacing(3), diff --git a/src/features/auth/hooks/useSocialLogin.ts b/src/features/auth/hooks/useSocialLogin.ts index beb08f4..132227f 100644 --- a/src/features/auth/hooks/useSocialLogin.ts +++ b/src/features/auth/hooks/useSocialLogin.ts @@ -2,6 +2,7 @@ import { signInWithPopup, fetchSignInMethodsForEmail, getAdditionalUserInfo, + type AuthError, } from "firebase/auth"; import type { AuthProvider } from "firebase/auth"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -18,46 +19,92 @@ export const useSocialLogin = (): { const socialLogin = async (provider: AuthProvider): Promise => { try { const result = await signInWithPopup(auth, provider); - const user = result.user; - - console.log("소셜 로그인 성공: ", user); // Firebase 신규 유저 여부 확인 const additionalInfo = getAdditionalUserInfo(result); const isNewUser = additionalInfo?.isNewUser; if (isNewUser) { - navigate("/signup"); + navigate("/signup", { state: { fromSocial: true } }); } else { navigate(redirect); } - } catch (error: any) { - console.error("소셜 로그인 실패: ", error); - - if (error.code !== "auth/account-exists-with-different-credential") - return; + } catch (error: unknown) { + const authError = error as AuthError; - const email = error.customData?.email; - if (!email) { - alert("이메일 정보를 가져올 수 없습니다."); + // 계정 충돌 에러 처리 + if (authError.code === "auth/account-exists-with-different-credential") { + await handleAccountConflict(authError); return; } + // 기타 에러 처리 + handleGeneralError(authError); + } + }; + + const handleAccountConflict = async (error: AuthError): Promise => { + const email = error.customData?.email; + if (!email) { + showErrorSnackbar("이메일 정보를 가져올 수 없습니다."); + return; + } + + try { const methods = await fetchSignInMethodsForEmail(auth, email); if (methods.length === 0) { - alert("이미 다른 로그인 방법으로 가입된 이메일입니다."); + showErrorSnackbar("이미 다른 로그인 방법으로 가입된 이메일입니다."); return; } if (methods.includes("google.com")) { - alert( + showErrorSnackbar( "이미 Google 계정으로 가입된 이메일입니다. Google 로그인을 이용해주세요." ); } else { - alert(`이미 가입된 로그인 방법: ${methods.join(", ")}`); + showErrorSnackbar(`이미 가입된 로그인 방법: ${methods.join(", ")}`); } + } catch { + showErrorSnackbar("로그인 방법 조회 중 오류가 발생했습니다."); } }; + const handleGeneralError = (error: AuthError): void => { + // 일반적인 에러 코드별 처리 + switch (error.code) { + case "auth/popup-closed-by-user": + // 사용자가 팝업을 닫은 경우는 알림하지 않음 + break; + case "auth/popup-blocked": + showErrorSnackbar("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); + break; + case "auth/network-request-failed": + showErrorSnackbar("네트워크 연결을 확인해주세요."); + break; + case "auth/user-disabled": + showErrorSnackbar("비활성화된 계정입니다."); + break; + case "auth/invalid-credential": + showErrorSnackbar("잘못된 로그인 정보입니다."); + break; + case "auth/operation-not-allowed": + showErrorSnackbar("해당 로그인 방법이 허용되지 않습니다."); + break; + case "auth/too-many-requests": + showErrorSnackbar( + "너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요." + ); + break; + default: + showErrorSnackbar("로그인 중 오류가 발생했습니다. 다시 시도해주세요."); + } + }; + + const showErrorSnackbar = (message: string): void => { + // TODO: 전역 스낵바 상태 관리로 에러 메시지 표시 + // 예: useSnackbarStore.getState().showError(message); + console.error("로그인 에러:", message); + }; + return { socialLogin }; }; diff --git a/src/features/auth/hooks/useUserProfile.ts b/src/features/auth/hooks/useUserProfile.ts deleted file mode 100644 index 3038f9e..0000000 --- a/src/features/auth/hooks/useUserProfile.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; - -import { getUser } from "@entities/user/api/userApi"; - -import type { User } from "@shared/types/user"; - -export function useUserProfile(uid: string): UseQueryResult { - return useQuery({ - queryKey: ["userProfile", uid], - queryFn: () => getUser(uid), - enabled: !!uid, // uid가 있을 때만 쿼리 실행 - }); -} diff --git a/src/features/projects/hook/useProjectInsertForm.ts b/src/features/projects/hook/useProjectInsertForm.ts index 2c7a37f..2b4b7c3 100644 --- a/src/features/projects/hook/useProjectInsertForm.ts +++ b/src/features/projects/hook/useProjectInsertForm.ts @@ -123,6 +123,7 @@ const initForm4 = { // 테스트용 form 입니다. const TestData = (user: User): ProjectItemInsertReq => ({ + projectOwnerID: user.id, // 요거 추가!! projectOwner: { id: user.id, name: user.name, diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index 81afb70..a07c3c3 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -8,7 +8,6 @@ import ProjectCard from "@entities/projects/ui/projects-card/ProjectCard"; import ProjectsStats from "@entities/projects/ui/projects-stats/ProjectsStats"; const HomePage = (): JSX.Element => { - console.log("API_KEY: ", import.meta.env.VITE_API_KEY); const { data: projects } = useGetProjects({ pageSize: 3 }); return ( diff --git a/src/pages/login/ui/LoginPage.tsx b/src/pages/login/ui/LoginPage.tsx index 8461f08..8908cf6 100644 --- a/src/pages/login/ui/LoginPage.tsx +++ b/src/pages/login/ui/LoginPage.tsx @@ -6,7 +6,6 @@ import BackToHome from "@widgets/BackToHome/BackToHome"; import { LoginForm } from "@features/auth/ui/LoginForm"; const LoginPage = (): JSX.Element => { - console.log("API_KEY: ", import.meta.env.VITE_API_KEY); return ( diff --git a/src/pages/signup/ui/SignUpPage.tsx b/src/pages/signup/ui/SignUpPage.tsx index f79e6e9..f61b916 100644 --- a/src/pages/signup/ui/SignUpPage.tsx +++ b/src/pages/signup/ui/SignUpPage.tsx @@ -1,11 +1,33 @@ import { Box, Paper, styled, Typography } from "@mui/material"; import type { JSX } from "react"; +import { useState } from "react"; +import { useLocation, Navigate } from "react-router-dom"; import BackToHome from "@widgets/BackToHome/BackToHome"; import UserInfoForm from "@entities/user/ui/UserInfoForm"; +import SnackbarAlert from "@shared/ui/SnackbarAlert"; + const SignUpPage = (): JSX.Element => { + const location = useLocation(); + const fromSocial = location.state?.fromSocial; + const [open, setOpen] = useState(!fromSocial); + + if (!fromSocial) { + if (open) { + return ( + setOpen(false)} + message="잘못된 접근입니다." + severity="error" + /> + ); + } + return ; + } + return ( diff --git a/src/pages/user-profile/ui/UserProfilePage.tsx b/src/pages/user-profile/ui/UserProfilePage.tsx index 99e2b46..cd5fcb9 100644 --- a/src/pages/user-profile/ui/UserProfilePage.tsx +++ b/src/pages/user-profile/ui/UserProfilePage.tsx @@ -3,7 +3,6 @@ import { styled as muiStyled } from "@mui/material/styles"; import type { JSX } from "react"; import { useState, useEffect } from "react"; -import { useUserProfile } from "@features/auth/hooks/useUserProfile"; import { removeProjectsFromUser } from "@features/projects/api/projectsApi"; import { useProjectsByIds } from "@entities/projects/hook/useProjectsByIds"; @@ -11,6 +10,7 @@ import UserProfileCard from "@entities/user/ui/user-profile/UserProfileCard"; import UserProfileHeader from "@entities/user/ui/user-profile/UserProfileHeader"; import UserProfileProjectList from "@entities/user/ui/user-profile/UserProfileProjectList"; +import { useUserProfile } from "@shared/queries/useUserProfile"; import { useAuthStore } from "@shared/stores/authStore"; import { useProjectStore } from "@shared/stores/projectStore"; import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; @@ -71,6 +71,7 @@ const UserProfilePage = (): JSX.Element => { gap={4} flexDirection={{ xs: "column", sm: "row" }} position="relative" + alignItems="flex-start" > {/* 왼쪽 프로필 사이드바 */} => { } return null; }; + +export const updateUser = async ( + uid: string, + userInfo: Partial +): Promise => { + const userDoc = doc(db, "users", uid); + await updateDoc(userDoc, userInfo); +}; diff --git a/src/shared/types/project.ts b/src/shared/types/project.ts index 46f3531..fc600f5 100644 --- a/src/shared/types/project.ts +++ b/src/shared/types/project.ts @@ -4,6 +4,7 @@ import type { ExpectedPeriod, ProjectSchedule } from "@shared/types/schedule"; import type { User, UserRole } from "@shared/types/user"; export interface ProjectItemInsertReq { + projectOwnerID: string; // 작성자 projectOwner: User; // 프로젝트 오너 유저 정보 status: RecruitmentStatus; category: ProjectCategory; // 프로젝트 분야