Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bc6029d
resolve: merge 해결
tkyoun0421 Jun 28, 2025
af8514a
fix: 프로젝트 상세 줄바꿈, 지원하기 버튼 개선
czmcm5 Jun 28, 2025
a44795e
fix: 로고 경로에러 해결
czmcm5 Jun 28, 2025
54df315
refactor: 프로젝트 삭제 로직 useDeleteProjectsMutation 훅으로 통합 및 리팩토링
namee-h Jun 28, 2025
d7029bd
feat: email 전송 기능 구현
tkyoun0421 Jun 28, 2025
6d16067
fix: step4 데이터 누락 문제 해결을 위해 setTimeout을 useEffect로 변경
MINYOUNG-SEOK Jun 28, 2025
afc6bcc
fix: useEffect 의존성 경고 해결을 위해 submit 함수를 내부로 이동
MINYOUNG-SEOK Jun 28, 2025
453bdcd
feat: 미팅 후 추가 사항 구현
tkyoun0421 Jun 28, 2025
9008e72
resolve: lint 오류 해결
tkyoun0421 Jun 28, 2025
ffc8c9c
feat: 우대사항 카드에 엔터키 지원 추가
MINYOUNG-SEOK Jun 28, 2025
79edce7
fix: 공유하기 alert창 수정
czmcm5 Jun 28, 2025
d946848
refactor: 망가졌던 삭제기능 복구
namee-h Jun 28, 2025
9f2df93
chore: 검색 페이지 페이지네이션 스크롤 오류 제거
tkyoun0421 Jun 28, 2025
ecef9e4
chore: 쓸모 없는 조건 제거
tkyoun0421 Jun 28, 2025
b42b779
Merge branch 'develop' into feat/insert
MINYOUNG-SEOK Jun 28, 2025
d3ef2ae
feat: 폼 유효성 검사에서 alert을 스낵바로 교체
MINYOUNG-SEOK Jun 28, 2025
92b62fc
fix: 스케쥴 기간 타입 수정
MINYOUNG-SEOK Jun 28, 2025
877db2d
refactor: projectCategory enum을 사용하도록 변경
MINYOUNG-SEOK Jun 28, 2025
46342b2
refactor: 수정하기 버튼 삭제
namee-h Jun 28, 2025
1b2d20a
refactor: step3에서 로컬 schedule 타입을 공통 projectschedule로 통합
MINYOUNG-SEOK Jun 28, 2025
2cdd141
Merge pull request #73 from czmcm5/feat/detail
tkyoun0421 Jun 28, 2025
9244cfa
Merge pull request #74 from namee-h/feat/profile
tkyoun0421 Jun 28, 2025
3b30e45
Merge pull request #75 from amicable-development-center/feat/email
tkyoun0421 Jun 28, 2025
4a689f2
Merge pull request #76 from MINYOUNG-SEOK/feat/insert
tkyoun0421 Jun 28, 2025
cc07215
chore: console.log 제거
tkyoun0421 Jun 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ dist-ssr
*.sln
*.sw?

.env
.env
.vercel
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
]
},
"dependencies": {
"@emailjs/browser": "^4.4.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.2",
Expand All @@ -57,6 +58,7 @@
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^8.34.1",
"@typescript-eslint/parser": "^8.34.1",
"@vercel/node": "^5.3.0",
"@vitejs/plugin-react": "^4.4.1",
"babel-plugin-import": "^1.13.8",
"eslint": "^8.57.1",
Expand All @@ -79,5 +81,8 @@
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
},
"engines": {
"node": "18.x"
}
}
914 changes: 914 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

15 changes: 6 additions & 9 deletions src/entities/projects/api/getProjectLikeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,11 @@ export const deleteUserLikes = async (
): Promise<void> => {
if (!userId || !projectIds.length) return;

const q = query(
collection(db, "likes"),
where("userId", "==", userId),
where("projectId", "in", projectIds)
);
const snapshot = await getDocs(q);
const deletePromises = snapshot.docs.map((docSnap) =>
deleteDoc(doc(db, "likes", docSnap.id))
);
// 문서 ID를 직접 생성하여 삭제 (더 효율적)
const deletePromises = projectIds.map((projectId) => {
const likeId = `${userId}_${projectId}`;
return deleteDoc(doc(db, "likes", likeId));
});

await Promise.all(deletePromises);
};
63 changes: 63 additions & 0 deletions src/entities/projects/api/projectsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
doc,
getDoc,
where,
deleteDoc,
updateDoc,
arrayRemove,
} from "firebase/firestore";

import { db } from "@shared/firebase/firebase";
Expand Down Expand Up @@ -111,3 +114,63 @@ export const getProjectsByIds = async (
);
return results;
};

/** 여러 프로젝트를 완전히 삭제 (likes, applications, projects, users 컬렉션 모두) */
export const deleteProjectsEverywhere = async (
projectIds: string[],
userId: string
): Promise<{ success: boolean; error?: string }> => {
try {
// 모든 삭제 작업을 병렬로 실행하기 위한 함수들

// 좋아요 삭제 - 프로젝트 ID로 모든 좋아요 찾아서 삭제
const deleteLikesForProject = async (
projectId: string
): Promise<void[]> => {
const likesSnap = await getDocs(
query(collection(db, "likes"), where("projectId", "==", projectId))
);
return Promise.all(likesSnap.docs.map((doc) => deleteDoc(doc.ref)));
};

const deleteApplicationsForProject = async (
projectId: string
): Promise<void[]> => {
const appsSnap = await getDocs(
query(
collection(db, "applications"),
where("projectId", "==", projectId)
)
);
return Promise.all(appsSnap.docs.map((doc) => deleteDoc(doc.ref)));
};

const deleteProject = async (projectId: string): Promise<void> => {
return deleteDoc(doc(db, "projects", projectId));
};

// 모든 작업을 병렬로 실행
await Promise.all([
// 1. likes 컬렉션에서 모든 프로젝트의 likes 삭제 (병렬)
...projectIds.map(deleteLikesForProject),

// 2. applications 컬렉션에서 모든 프로젝트의 applications 삭제 (병렬)
...projectIds.map(deleteApplicationsForProject),

// 3. projects 컬렉션에서 모든 프로젝트 삭제 (병렬)
...projectIds.map(deleteProject),

// 4. users 컬렉션에서 myProjects, likeProjects, appliedProjects에서 제거
updateDoc(doc(db, "users", userId), {
myProjects: arrayRemove(...projectIds),
likeProjects: arrayRemove(...projectIds),
appliedProjects: arrayRemove(...projectIds),
}),
]);

return { success: true };
} catch (err) {
console.error(err);
return { success: false, error: "프로젝트 완전 삭제 실패" };
}
};
204 changes: 204 additions & 0 deletions src/entities/projects/hooks/useDeleteProjectsMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";

import { deleteApplication } from "@entities/projects/api/getProjectApplicationsApi";
import { deleteUserLikes } from "@entities/projects/api/getProjectLikeApi";
import { deleteProjectsEverywhere } from "@entities/projects/api/projectsApi";

import queryKeys from "@shared/react-query/queryKey";
import { useLikeStore } from "@shared/stores/likeStore";
import { useProjectStore } from "@shared/stores/projectStore";
import { useSnackbarStore } from "@shared/stores/snackbarStore";
import { ProjectCollectionTabType } from "@shared/types/project";
import type { ProjectListRes } from "@shared/types/project";

interface DeleteProjectsParams {
type: ProjectCollectionTabType;
ids: string[];
user: { uid: string } | null;
appliedProjectsData?: ProjectListRes[];
myLikedProjectsData?: ProjectListRes[];
}

const ERROR_MSG = "프로젝트 삭제에 실패했습니다.";

export const useDeleteProjectsMutation = (): UseMutationResult<
void,
unknown,
DeleteProjectsParams
> => {
const queryClient = useQueryClient();
const { removeLikeProjects } = useLikeStore();
const { setAppliedProjects, setLikeProjects } = useProjectStore();
const { showSuccess, showError } = useSnackbarStore();

// 관심 프로젝트 삭제
const deleteLikes = async (
userUid: string,
ids: string[],
myLikedProjectsData?: ProjectListRes[]
): Promise<void> => {
await deleteUserLikes(userUid, ids);

// 전역 상태 동기화
removeLikeProjects(ids);
setLikeProjects(
myLikedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) ||
[]
);

showSuccess("관심 프로젝트가 삭제되었습니다.");
};

// 지원한 프로젝트 삭제
const deleteApplied = async (
userUid: string,
ids: string[],
appliedProjectsData?: ProjectListRes[]
): Promise<void> => {
// applications 컬렉션에서 제거 (병렬 처리)
const deletePromises = ids.map((projectId) =>
deleteApplication(userUid, projectId)
);
await Promise.all(deletePromises);

// 전역 상태 동기화
setAppliedProjects(
appliedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) ||
[]
);

showSuccess("지원한 프로젝트가 삭제되었습니다.");
};

// 만든 프로젝트 삭제
const deleteCreated = async (
userUid: string,
ids: string[],
appliedProjectsData?: ProjectListRes[],
myLikedProjectsData?: ProjectListRes[]
): Promise<void> => {
const res = await deleteProjectsEverywhere(ids, userUid);

if (!res.success) {
showError(res.error || ERROR_MSG);
throw new Error(res.error || ERROR_MSG);
}

// 전역 상태 동기화
setAppliedProjects(
appliedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) ||
[]
);
setLikeProjects(
myLikedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) ||
[]
);
removeLikeProjects(ids);

showSuccess("만든 프로젝트가 삭제되었습니다.");
};

// 쿼리 무효화 함수들
const invalidateLikeQueries = async (): Promise<void> => {
const queries = [
[queryKeys.myLikedProjects, "details"],
[queryKeys.myLikedProjects, "ids"],
[queryKeys.projectLike],
[queryKeys.projectLikedUser],
[queryKeys.projects], // 홈페이지, 프로젝트 찾기 페이지 동기화
];

await Promise.all(
queries.map((queryKey) => queryClient.invalidateQueries({ queryKey }))
);
};

const invalidateAppliedQueries = async (): Promise<void> => {
const queries = [
[queryKeys.myAppliedProjects, "details"],
[queryKeys.myAppliedProjects, "ids"],
[queryKeys.projectAppliedUser],
];

await Promise.all(
queries.map((queryKey) => queryClient.invalidateQueries({ queryKey }))
);
};

const invalidateCreatedQueries = async (userUid: string): Promise<void> => {
const queries = [
[queryKeys.myLikedProjects, "details"],
[queryKeys.myAppliedProjects, "details"],
[queryKeys.projects],
["userProfile", userUid],
[queryKeys.projectLike],
[queryKeys.projectLikedUser],
[queryKeys.projectAppliedUser],
];

await Promise.all(
queries.map((queryKey) => queryClient.invalidateQueries({ queryKey }))
);
};

// 타입별 쿼리 무효화
const invalidateQueries = async (
type: ProjectCollectionTabType,
user?: { uid: string } | null
): Promise<void> => {
switch (type) {
case ProjectCollectionTabType.Likes:
await invalidateLikeQueries();
break;
case ProjectCollectionTabType.Applied:
await invalidateAppliedQueries();
break;
case ProjectCollectionTabType.Created:
if (user) {
await invalidateCreatedQueries(user.uid);
}
break;
}
};

// 메인 삭제 로직
const handleDelete = async ({
type,
ids,
user,
appliedProjectsData,
myLikedProjectsData,
}: DeleteProjectsParams): Promise<void> => {
if (!user) {
throw new Error("로그인이 필요합니다.");
}

switch (type) {
case ProjectCollectionTabType.Likes:
await deleteLikes(user.uid, ids, myLikedProjectsData);
break;
case ProjectCollectionTabType.Applied:
await deleteApplied(user.uid, ids, appliedProjectsData);
break;
case ProjectCollectionTabType.Created:
await deleteCreated(
user.uid,
ids,
appliedProjectsData,
myLikedProjectsData
);
break;
}
};

return useMutation<void, unknown, DeleteProjectsParams>({
mutationFn: handleDelete,
onSuccess: async (_data, variables): Promise<void> => {
await invalidateQueries(variables.type, variables.user);
},
onError: (error: any): void => {
showError(error?.message || ERROR_MSG);
},
});
};
11 changes: 11 additions & 0 deletions src/entities/projects/queries/useGetProjectLike.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getProjectLikedUsers,
getProjectLikeStatus,
} from "@entities/projects/api/getProjectLikeApi";
import { getProjectsByIds } from "@entities/projects/api/projectsApi";

import queryKeys from "@shared/react-query/queryKey";
import { useAuthStore } from "@shared/stores/authStore";
Expand Down Expand Up @@ -62,3 +63,13 @@ export const useGetMyLikedProjectsWithDetails = (): UseQueryResult<
enabled: !!user,
});
};

export const useGetMyCreatedProjectsWithDetails = (
myProjectsIds?: string[]
): UseQueryResult<ProjectListRes[], Error> => {
return useQuery({
queryKey: [queryKeys.myCreatedProjects, "details", myProjectsIds],
queryFn: () => getProjectsByIds(myProjectsIds || []),
enabled: !!myProjectsIds && myProjectsIds.length > 0,
});
};
19 changes: 18 additions & 1 deletion src/entities/projects/ui/post-info/ProjectLeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ import MailOutlineIcon from "@mui/icons-material/MailOutline";
import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt";
import { Box, styled, Typography } from "@mui/material";
import type { JSX } from "react";
import { useLocation, useNavigate } from "react-router-dom";

import { useAuthStore } from "@shared/stores/authStore";
import type { User } from "@shared/types/user";
import TitleWithIcon from "@shared/ui/project-detail/TitleWithIcon";

const ProjectLeader = ({
projectOwner,
onEmailClick,
}: {
projectOwner: User | undefined;
onEmailClick?: () => void;
}): JSX.Element | null => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
if (!projectOwner) return null;

return (
Expand Down Expand Up @@ -40,8 +47,18 @@ const ProjectLeader = ({
{projectOwner.introduceMyself || "아직 등록한 소개가 없어요! 🚀"}
</Typography>

<MessageBtn>
<MessageBtn
onClick={() => {
if (!user) {
navigate(
`/login?redirect=${encodeURIComponent(location.pathname)}`
);
}
onEmailClick?.();
}}
>
<MailOutlineIcon />
{}
<Typography variant="button">연락하기</Typography>
</MessageBtn>
</>
Expand Down
Loading