diff --git a/src/app/styles/global.css b/src/app/styles/global.css index ef8f4e1..d24c2df 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -52,6 +52,7 @@ body { "Droid Sans", "Helvetica Neue", sans-serif; + background-color: #f8fafc; } *, diff --git a/src/features/projects/hooks/useApplyForm.ts b/src/features/projects/hooks/useApplyForm.ts new file mode 100644 index 0000000..91adaa7 --- /dev/null +++ b/src/features/projects/hooks/useApplyForm.ts @@ -0,0 +1,90 @@ +import { useState, type ChangeEvent } from "react"; +import { useParams } from "react-router-dom"; + +import { useCancelProjectApplication } from "@features/projects/queries/useCancelProjectApplication"; +import { useCreateProjectApplications } from "@features/projects/queries/useCreateProjectApplications"; + +import { useGetProjectApplicationStatus } from "@entities/projects/queries/useGetProjectApplications"; + +import { useSnackbarStore } from "@shared/stores/snackbarStore"; + +interface ApplyFormResult { + openForm: { + isOpen: boolean; + open: () => void; + close: () => void; + }; + message: { + value: string; + update: (e: ChangeEvent) => void; + }; + submit: () => void; + cancle: () => void; + isPending: boolean; + isCancling: boolean; + isApplied: boolean; +} + +const useApplyForm = (): ApplyFormResult => { + const { id: projectId } = useParams(); + const { showError } = useSnackbarStore(); + + const { data: isApplied = false, isLoading: dataLoading } = + useGetProjectApplicationStatus(); + const { mutate: createProjectApplication, isPending: createPending } = + useCreateProjectApplications(); + const { mutate: cancelProjectApplication, isPending: cancelPending } = + useCancelProjectApplication(); + + const [isFormOpen, setIsFormOpen] = useState(false); + const [applyMessage, setApplyMessage] = useState(""); + + const openForm = (): void => { + if (dataLoading) return; + setIsFormOpen(true); + }; + const closeForm = (): void => setIsFormOpen(false); + + const updateApplyMessage = (e: ChangeEvent): void => + setApplyMessage(e.target.value); + + const handleApplySubmit = (): void => { + if (!projectId) return; + if (!applyMessage.trim()) { + showError("메시지를 입력해주세요."); + return; + } + + createProjectApplication(applyMessage.trim(), { + onSuccess: () => closeForm, + }); + }; + + const handleCancelSubmit = (): void => { + if (!projectId) return; + + const isRealCancle = confirm("정말로 지원을 취소하시겠습니까?"); + if (!isRealCancle) return; + + cancelProjectApplication(projectId); + }; + + return { + openForm: { + isOpen: isFormOpen, + open: openForm, + close: closeForm, + }, + message: { + value: applyMessage, + update: updateApplyMessage, + }, + submit: handleApplySubmit, + cancle: handleCancelSubmit, + isPending: createPending, + isCancling: cancelPending, + isApplied, + }; +}; + +export default useApplyForm; diff --git a/src/features/projects/hooks/useApplyFrom.ts b/src/features/projects/hooks/useApplyFrom.ts deleted file mode 100644 index 9bfe6f6..0000000 --- a/src/features/projects/hooks/useApplyFrom.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useState, type ChangeEvent } from "react"; - -import useProjectApply from "@features/projects/queries/useProjectApply"; - -interface ApplyFormResult { - openForm: { - isOpen: boolean; - open: () => void; - close: () => void; - }; - message: { - value: string; - update: (e: ChangeEvent) => void; - }; - submit: () => void; -} - -const useApplyFrom = (projectID: string): ApplyFormResult => { - const { mutate: updateApply } = useProjectApply(); - - const [isFormOpen, setIsFormOpen] = useState(false); - const [applyMessage, setApplyMessage] = useState(""); - - const openForm = (): void => setIsFormOpen(true); - const closeForm = (): void => setIsFormOpen(false); - - const updateApplyMessage = (e: ChangeEvent): void => - setApplyMessage(e.target.value); - - const submit = (): void => { - if (!projectID) return; - if (!applyMessage.trim()) { - alert("메세지를 적어주세요"); - return; - } - updateApply(projectID); - }; - - return { - openForm: { - isOpen: isFormOpen, - open: openForm, - close: closeForm, - }, - message: { - value: applyMessage, - update: updateApplyMessage, - }, - submit, - }; -}; - -export default useApplyFrom; diff --git a/src/features/projects/hooks/useOptimisticProjectLike.ts b/src/features/projects/hooks/useOptimisticProjectLike.ts new file mode 100644 index 0000000..fdce5c4 --- /dev/null +++ b/src/features/projects/hooks/useOptimisticProjectLike.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; + +import { useToggleProjectLikeSync } from "@features/projects/queries/useCreateProjectLike"; + +import { useGetProjectLike } from "@entities/projects/queries/useGetProjectLike"; + +import { useLikeStore } from "@shared/stores/likeStore"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; + +interface UseOptimisticProjectLikeProps { + isLiked: boolean; + toggleLike: () => void; +} + +const DEBOUNCE_DELAY_MS = 100; + +export const useOptimisticProjectLike = (): UseOptimisticProjectLikeProps => { + const { id: projectId } = useParams(); + const { data: serverLikeStatus, isLoading } = useGetProjectLike(); + const { mutate: syncToServer } = useToggleProjectLikeSync(); + const { addLikedProject, removeLikedProject } = useLikeStore(); + const { showSuccess } = useSnackbarStore(); + + const [optimisticLikeStatus, setOptimisticLikeStatus] = useState< + boolean | undefined + >(serverLikeStatus); + const debounceTimerRef = useRef(null); + const pendingServerSync = useRef(false); + + useEffect(() => { + if (serverLikeStatus !== undefined && !pendingServerSync.current) { + setOptimisticLikeStatus(serverLikeStatus); + } + }, [serverLikeStatus]); + + useEffect(() => { + // projectId을 바꾸려면 사실상 Url을 직접 바쒀서 진입할 수 밖에 없긴한데 + // 이 경우 해당 훅을 불러오는 컴포넌트가 언마운트 되었다가 다시 마운트 되는 구조라 + // if문이 실행되는 경우를 알 수가 없지만 + // pendingServerSync.current = false;를 꼭 해야한다면 + // 의존성 [] 이어도 될 같습니다 ... 만 나중에 천천히 알아보며 리팩토링 하기로하고 남겨두겟습니다 + return () => { + if (debounceTimerRef.current) { + console.log("???//sDFsdfsdf"); + clearTimeout(debounceTimerRef.current); + } + pendingServerSync.current = false; + }; + }, [projectId]); + + const toggleLike = (): void => { + if (!projectId || isLoading) return; + + const newLikeStatus = !optimisticLikeStatus; + setOptimisticLikeStatus(newLikeStatus); + + // 전역 상태도 업데이트 + if (newLikeStatus) { + addLikedProject(projectId); + showSuccess("좋아요 되었습니다."); + } else { + removeLikedProject(projectId); + showSuccess("좋아요가 취소 되었습니다."); + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + pendingServerSync.current = true; + debounceTimerRef.current = setTimeout(() => { + syncToServer(projectId, { + onSettled: () => { + pendingServerSync.current = false; + }, + }); + }, DEBOUNCE_DELAY_MS); + }; + + const displayLikeStatus = optimisticLikeStatus ?? serverLikeStatus ?? false; + + return { + isLiked: displayLikeStatus, + toggleLike, + }; +}; diff --git a/src/features/projects/queries/useCancelProjectApplication.ts b/src/features/projects/queries/useCancelProjectApplication.ts index a51e5e6..a5b2c25 100644 --- a/src/features/projects/queries/useCancelProjectApplication.ts +++ b/src/features/projects/queries/useCancelProjectApplication.ts @@ -7,6 +7,7 @@ import { import queryKeys from "@shared/react-query/queryKey"; import { useApplicationsStore } from "@shared/stores/applicationsStore"; import { useAuthStore } from "@shared/stores/authStore"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; import { cancelProjectApplications } from "../api/createProjectApplicationsApi"; @@ -21,6 +22,7 @@ export const useCancelProjectApplication = (): UseMutationResult< > => { const { user } = useAuthStore(); const { removeAppliedProject } = useApplicationsStore(); + const { showError, showSuccess } = useSnackbarStore(); const queryClient = useQueryClient(); return useMutation({ @@ -29,6 +31,8 @@ export const useCancelProjectApplication = (): UseMutationResult< }, onSuccess: (_, projectId) => { removeAppliedProject(projectId); + showSuccess("지원이 취소되었습니다."); + queryClient.invalidateQueries({ queryKey: [queryKeys.myAppliedProjects], }); @@ -41,6 +45,7 @@ export const useCancelProjectApplication = (): UseMutationResult< }, onError: (error) => { console.error("지원 취소 실패:", error); + showError(`지원 취소 실패: ${error.message}`); }, }); }; diff --git a/src/features/projects/queries/useCreateProjectApplications.ts b/src/features/projects/queries/useCreateProjectApplications.ts index 475c9e8..843d260 100644 --- a/src/features/projects/queries/useCreateProjectApplications.ts +++ b/src/features/projects/queries/useCreateProjectApplications.ts @@ -3,41 +3,60 @@ import { useQueryClient, type UseMutationResult, } from "@tanstack/react-query"; +import type { User } from "firebase/auth"; +import { useParams } from "react-router-dom"; import { createProjectApplication } from "@features/projects/api/createProjectApplicationsApi"; import queryKeys from "@shared/react-query/queryKey"; import { useApplicationsStore } from "@shared/stores/applicationsStore"; -import type { CreateProjectApplicationInput } from "@shared/types/project"; +import { useAuthStore } from "@shared/stores/authStore"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; /** * 프로젝트 지원 생성 훅 - * @returns UseMutationResult - 프로젝트 지원 생성 결과 + * @returns UseMutationResult - 프로젝트 지원 생성 결과 */ export const useCreateProjectApplications = (): UseMutationResult< void, Error, - CreateProjectApplicationInput + string > => { - const queryClient = useQueryClient(); + const { id: projectId } = useParams(); + const { user } = useAuthStore(); const { addAppliedProject } = useApplicationsStore(); + const { showError, showSuccess } = useSnackbarStore(); + const queryClient = useQueryClient(); return useMutation({ - mutationFn: (input: CreateProjectApplicationInput) => { - return createProjectApplication(input); - }, + mutationFn: (message: string) => { + if (!projectId) { + return Promise.reject(new Error("projectId가 없서요.")); + } - onSuccess: (_data, input) => { - addAppliedProject(input.projectId); + return createProjectApplication({ + userId: user?.uid as User["uid"], + projectId, + message, + }); }, - onSettled: (_data, _error, input) => { + onSuccess: (_data) => { + if (!projectId) return; + addAppliedProject(projectId); + showSuccess("지원이 완료되었습니다! 🎉"); + }, + onSettled: (_data, _error) => { queryClient.invalidateQueries({ - queryKey: [queryKeys.projectApply, input.projectId], + queryKey: [queryKeys.projectApply, projectId], }); queryClient.invalidateQueries({ - queryKey: [queryKeys.projectAppliedUser, input.projectId], + queryKey: [queryKeys.projectAppliedUser, projectId], }); }, + onError: (error) => { + console.error("지원 실패:", error); + showError(`지원 실패: ${error.message}`); + }, }); }; diff --git a/src/features/projects/queries/useCreateProjectLike.ts b/src/features/projects/queries/useCreateProjectLike.ts index fd20bbe..d29dba2 100644 --- a/src/features/projects/queries/useCreateProjectLike.ts +++ b/src/features/projects/queries/useCreateProjectLike.ts @@ -3,20 +3,13 @@ import { useQueryClient, type UseMutationResult, } from "@tanstack/react-query"; -import { useCallback, useRef, useState, useEffect } from "react"; -import { useParams } from "react-router-dom"; import { toggleProjectLike } from "@features/projects/api/createProjectLikeApi"; -import { useGetProjectLike } from "@entities/projects/queries/useGetProjectLike"; - import queryKeys from "@shared/react-query/queryKey"; import { useAuthStore } from "@shared/stores/authStore"; -import { useLikeStore } from "@shared/stores/likeStore"; import type { ToggleProjectLikeResponse } from "@shared/types/like"; -const DEBOUNCE_DELAY_MS = 100; - export const useToggleProjectLikeSync = (): UseMutationResult< ToggleProjectLikeResponse, Error, @@ -44,79 +37,3 @@ export const useToggleProjectLikeSync = (): UseMutationResult< }, }); }; - -interface UseOptimisticProjectLikeProps { - isLiked: boolean; - isLoading: boolean; - toggleLike: () => void; -} - -export const useOptimisticProjectLike = (): UseOptimisticProjectLikeProps => { - const { id: projectId } = useParams(); - const { data: serverLikeStatus, isLoading } = useGetProjectLike(); - const { mutate: syncToServer } = useToggleProjectLikeSync(); - const { addLikedProject, removeLikedProject } = useLikeStore(); - - const [optimisticLikeStatus, setOptimisticLikeStatus] = useState< - boolean | undefined - >(serverLikeStatus); - const debounceTimerRef = useRef(null); - const pendingServerSync = useRef(false); - - useEffect(() => { - if (serverLikeStatus !== undefined && !pendingServerSync.current) { - setOptimisticLikeStatus(serverLikeStatus); - } - }, [serverLikeStatus]); - - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - pendingServerSync.current = false; - }; - }, [projectId]); - - const toggleLike = useCallback(() => { - if (!projectId || isLoading) return; - - const newLikeStatus = !optimisticLikeStatus; - setOptimisticLikeStatus(newLikeStatus); - - // 전역 상태도 업데이트 - if (newLikeStatus) { - addLikedProject(projectId); - } else { - removeLikedProject(projectId); - } - - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - pendingServerSync.current = true; - debounceTimerRef.current = setTimeout(() => { - syncToServer(projectId, { - onSettled: () => { - pendingServerSync.current = false; - }, - }); - }, DEBOUNCE_DELAY_MS); - }, [ - projectId, - isLoading, - optimisticLikeStatus, - addLikedProject, - removeLikedProject, - syncToServer, - ]); - - const displayLikeStatus = optimisticLikeStatus ?? serverLikeStatus ?? false; - - return { - isLiked: displayLikeStatus, - isLoading, - toggleLike, - }; -}; diff --git a/src/features/projects/ui/ProjectApplyForm.tsx b/src/features/projects/ui/ProjectApplyForm.tsx index 78bd9b4..06bf34d 100644 --- a/src/features/projects/ui/ProjectApplyForm.tsx +++ b/src/features/projects/ui/ProjectApplyForm.tsx @@ -1,80 +1,26 @@ import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; import { Box, styled, Typography } from "@mui/material"; -import type { User } from "firebase/auth"; import { type JSX } from "react"; -import { useParams } from "react-router-dom"; -import useApplyFrom from "@features/projects/hooks/useApplyFrom"; -import { useCancelProjectApplication } from "@features/projects/queries/useCancelProjectApplication"; -import { useCreateProjectApplications } from "@features/projects/queries/useCreateProjectApplications"; - -import { useGetProjectApplicationStatus } from "@entities/projects/queries/useGetProjectApplications"; - -import { useAuthStore } from "@shared/stores/authStore"; +import useApplyFrom from "@features/projects/hooks/useApplyForm"; const ProjectApplyForm = (): JSX.Element => { - const { id: projectId } = useParams(); - const user = useAuthStore((state) => state.user); - - const { openForm, message } = useApplyFrom(projectId || ""); - const { data: isApplied } = useGetProjectApplicationStatus(); - - const { mutate: createProjectApplication, isPending: createPending } = - useCreateProjectApplications(); - - const { mutate: cancelProjectApplication, isPending: cancelPending } = - useCancelProjectApplication(); - - const handleApplySubmit = (): void => { - if (!projectId || !message.value.trim()) { - alert("메시지를 입력해주세요."); - return; - } - - createProjectApplication( - { - userId: user?.uid as User["uid"], - projectId, - message: message.value.trim(), - }, - { - onSuccess: () => { - alert("지원이 완료되었습니다! 🎉"); - - openForm.close(); - }, - onError: (error) => { - alert(`지원 실패: ${error.message}`); - }, - } - ); - }; - - const handleCancelSubmit = (): void => { - if (!projectId) return; - - if (confirm("정말로 지원을 취소하시겠습니까?")) { - cancelProjectApplication(projectId, { - onSuccess: () => { - alert("지원이 취소되었습니다."); - }, - onError: (error) => { - alert(`지원 취소 실패: ${error.message}`); - }, - }); - } - }; + const { + openForm, + message, + submit, + cancle, + isApplied, + isCancling, + isPending, + } = useApplyFrom(); // 지원된 상태 if (isApplied) { return ( - + - {cancelPending ? "취소 중..." : "지원 취소"} + {isCancling ? "취소 중..." : "지원 취소"} ); @@ -86,7 +32,7 @@ const ProjectApplyForm = (): JSX.Element => { 지원하기 🚀 @@ -108,15 +54,15 @@ const ProjectApplyForm = (): JSX.Element => { /> - + 취소 - {createPending ? "지원 중..." : "지원하기"} + {isPending ? "지원 중..." : "지원하기"} diff --git a/src/features/projects/ui/ProjectLike.tsx b/src/features/projects/ui/ProjectLike.tsx index 0771df4..098926f 100644 --- a/src/features/projects/ui/ProjectLike.tsx +++ b/src/features/projects/ui/ProjectLike.tsx @@ -1,7 +1,7 @@ import { Box, styled } from "@mui/material"; import type { JSX } from "react"; -import { useOptimisticProjectLike } from "@features/projects/queries/useCreateProjectLike"; +import { useOptimisticProjectLike } from "@features/projects/hooks/useOptimisticProjectLike"; import { getStatusClassname, @@ -21,12 +21,7 @@ interface ProjectLikeProps { } const ProjectLike = ({ values }: ProjectLikeProps): JSX.Element => { - const { isLiked, isLoading, toggleLike } = useOptimisticProjectLike(); - - const handleLikeClick = (): void => { - if (isLoading) return; - toggleLike(); - }; + const { isLiked, toggleLike } = useOptimisticProjectLike(); return ( @@ -35,7 +30,7 @@ const ProjectLike = ({ values }: ProjectLikeProps): JSX.Element => { - + {isLiked ? ( ) : (