Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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.

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
15 changes: 13 additions & 2 deletions src/entities/projects/ui/projects-card/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Favorite as FavoriteIcon } from "@mui/icons-material";
import {
Box,
Button,
Expand All @@ -14,7 +15,10 @@ import { memo } from "react";
import { useNavigate } from "react-router-dom";

import { useGetProjectApplicationUsers } from "@entities/projects/queries/useGetProjectApplications";
import { useGetProjectLikedUsers } from "@entities/projects/queries/useGetProjectLike";
import {
useGetProjectLikedUsers,
useGetMyLikedProjectsIds,
} from "@entities/projects/queries/useGetProjectLike";

import { RecruitmentStatus, type ProjectListRes } from "@shared/types/project";
import DragScrollContainer from "@shared/ui/DragScrollContainer";
Expand All @@ -39,10 +43,13 @@ const ProjectCard = ({
const isRecruiting = project.status === RecruitmentStatus.recruiting;
const { data: likedUsers } = useGetProjectLikedUsers(project.id);
const { data: appliedUsers } = useGetProjectApplicationUsers(project.id);
const { data: myLikedProjectIds } = useGetMyLikedProjectsIds();

const likedUserCnt = likedUsers?.length || 0;
const appliedUsersCnt = appliedUsers?.length || 0;

const isLikedByCurrentUser = myLikedProjectIds?.includes(project.id) || false;

return (
<StyledCard
simple={simple}
Expand All @@ -68,7 +75,11 @@ const ProjectCard = ({
gap: 0.5,
}}
>
<FavoriteBorderIcon fontSize="large" color="error" />
{isLikedByCurrentUser ? (
<FavoriteIcon fontSize="large" color="error" />
) : (
<FavoriteBorderIcon fontSize="large" color="error" />
)}
<Typography variant="body1" color="text.secondary" fontWeight={500}>
{likedUserCnt}
</Typography>
Expand Down
26 changes: 19 additions & 7 deletions src/entities/search/hooks/useProjectSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, type RefObject } from "react";
import { useState, useCallback, useEffect, type RefObject } from "react";

import {
useProjectsByPage,
Expand Down Expand Up @@ -40,6 +40,8 @@ const useProjectSearch = (
}
);

const [shouldScrollAfterLoad, setShouldScrollAfterLoad] = useState(false);

const {
data: totalCount = 0,
isLoading: isCountLoading,
Expand All @@ -61,6 +63,17 @@ const useProjectSearch = (
const isLoading = isProjectsLoading || isCountLoading;
const isError = isProjectsError || isCountError;

useEffect(() => {
if (!isLoading && shouldScrollAfterLoad && resultsRef?.current) {
setTimeout(() => {
if (resultsRef?.current) {
scrollToElement(resultsRef.current, "smooth", 80);
}
}, 200);
setShouldScrollAfterLoad(false);
}
}, [isLoading, shouldScrollAfterLoad, resultsRef]);

const handleSearch = useCallback(
(filter: ProjectSearchFilterOption): void => {
setCurrentFilter(filter);
Expand All @@ -70,15 +83,14 @@ const useProjectSearch = (
);

const handlePageChange = (page: number): void => {
const isSamePage = page === currentPage;

if (isSamePage || isLoading) return;

setPage(page);

if (resultsRef?.current) {
scrollToElement(resultsRef.current, "smooth", 80);
}

if (page !== currentPage) {
setPage(page);
setShouldScrollAfterLoad(true);
}
};

return {
Expand Down
8 changes: 8 additions & 0 deletions src/entities/search/ui/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const HeaderSection = styled(Box)(({ theme }) => ({
flexDirection: "column",
gap: theme.spacing(3),
alignItems: "stretch",
padding: theme.spacing(2),
},
}));

Expand All @@ -143,6 +144,10 @@ const StatusArea = styled(Box, {
const SearchSection = styled(Box)(({ theme }) => ({
padding: `${theme.spacing(2)} ${theme.spacing(4)}`,
backgroundColor: theme.palette.background.paper,

[theme.breakpoints.down("md")]: {
padding: theme.spacing(1, 2),
},
}));

const SearchContainer = styled(Box)(() => ({
Expand All @@ -152,6 +157,9 @@ const SearchContainer = styled(Box)(() => ({
const FiltersSection = styled(Box)(({ theme }) => ({
padding: `0 ${theme.spacing(4)} ${theme.spacing(3)}`,
backgroundColor: theme.palette.background.paper,
[theme.breakpoints.down("md")]: {
padding: theme.spacing(1, 2),
},
}));

const ActionSection = styled(Box)(({ theme }) => ({
Expand Down
43 changes: 36 additions & 7 deletions src/entities/search/ui/SearchInputHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ interface SearchInputHistoryProps {
const HistoryDisabledMessage = (): JSX.Element => (
<DisabledMessage>
<HistoryToggleOff color="disabled" />
<Typography variant="body2" color="text.secondary">
<DisabledMessageText variant="body2" color="text.secondary">
검색 히스토리가 비활성화되어 있습니다
</Typography>
</DisabledMessageText>
</DisabledMessage>
);

const HistoryEmptyMessage = (): JSX.Element => (
<EmptyMessage>
<History color="disabled" />
<Typography variant="body2" color="text.secondary">
<EmptyMessageText variant="body2" color="text.secondary">
검색 히스토리가 없습니다
</Typography>
</EmptyMessageText>
</EmptyMessage>
);

Expand Down Expand Up @@ -79,7 +79,7 @@ const HistoryListContent = ({
<ListItemIcon>
<History fontSize="small" />
</ListItemIcon>
<ListItemText
<StyledListItemText
primary={historyItem}
primaryTypographyProps={{
noWrap: true,
Expand Down Expand Up @@ -117,9 +117,9 @@ const SearchHistoryHeader = ({
<HistoryHeader>
<HeaderContent>
<SearchInputHistoryToggle isHistoryEnabled={isHistoryEnabled} />
<Typography variant="body2" fontWeight={600}>
<HeaderTitle variant="body2" fontWeight={600}>
검색 히스토리
</Typography>
</HeaderTitle>
</HeaderContent>
<HeaderActions>
<StyledFormControlLabel
Expand Down Expand Up @@ -333,6 +333,9 @@ const ClearAllText = styled(Typography)(({ theme }) => ({
"&:hover": {
color: theme.palette.primary.main,
},
[theme.breakpoints.down("sm")]: {
fontSize: "0.875rem",
},
}));

const StyledDeleteButton = styled(IconButton)(({ theme }) => ({
Expand All @@ -341,4 +344,30 @@ const StyledDeleteButton = styled(IconButton)(({ theme }) => ({
},
}));

const DisabledMessageText = styled(Typography)(({ theme }) => ({
[theme.breakpoints.down("sm")]: {
fontSize: "1.3rem",
},
}));

const EmptyMessageText = styled(Typography)(({ theme }) => ({
[theme.breakpoints.down("sm")]: {
fontSize: "1.3rem",
},
}));

const StyledListItemText = styled(ListItemText)(({ theme }) => ({
"& .MuiListItemText-primary": {
[theme.breakpoints.down("sm")]: {
fontSize: "1.4rem",
},
},
}));

const HeaderTitle = styled(Typography)(({ theme }) => ({
[theme.breakpoints.down("sm")]: {
fontSize: "1.3rem",
},
}));

export default SearchInputHistory;
66 changes: 66 additions & 0 deletions src/features/email/api/emailApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import emailjs from "@emailjs/browser";

import type {
SendEmailRequest,
SendEmailResponse,
} from "@features/email/types/email";

const EMAIL_SERVICE_ID = import.meta.env.VITE_EMAIL_SERVICE_ID || "";
const EMAIL_TEMPLATE_ID = import.meta.env.VITE_EMAIL_TEMPLATE_ID || "";
const EMAIL_PUBLIC_KEY = import.meta.env.VITE_EMAIL_PUBLIC_KEY || "";

emailjs.init(EMAIL_PUBLIC_KEY);

export const sendEmailApi = async ({
actualSenderEmail,
receiverEmail,
projectId,
projectTitle,
emailData,
}: SendEmailRequest): Promise<SendEmailResponse> => {
try {
const templateParams = {
to_name: receiverEmail.split("@")[0],
to_email: receiverEmail,

from_name: actualSenderEmail.split("@")[0],
from_email: actualSenderEmail,

subject: emailData.subject,
message: emailData.message,

project_title: projectTitle,
project_id: projectId,

reply_to: actualSenderEmail,
};

const result = await emailjs.send(
EMAIL_SERVICE_ID,
EMAIL_TEMPLATE_ID,
templateParams
);

return {
success: true,
message: result.text,
};
} catch (error) {
let errorMessage = "이메일 전송에 실패했습니다. 다시 시도해주세요.";

if (error instanceof Error) {
if (error.message.includes("template")) {
errorMessage = "이메일 템플릿 설정에 문제가 있습니다.";
} else if (error.message.includes("service")) {
errorMessage = "이메일 서비스 설정에 문제가 있습니다.";
} else if (error.message.includes("user")) {
errorMessage = "이메일 서비스 인증에 실패했습니다.";
}
}

return {
success: false,
message: errorMessage,
};
}
};
Loading