diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8dc1962..f91155f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,7 +9,13 @@ jobs: build: runs-on: ubuntu-latest permissions: + id-token: write contents: read + env: + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_OIDC_ROLE_NAME }} + S3_BUCKET: ${{ secrets.AWS_BUCKET_NAME }} + CF_DISTRIBUTION_ID: ${{ secrets.AWS_DISTRIBUTION_ID }} steps: - name: Checkout source @@ -31,12 +37,12 @@ jobs: env: VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} - - name: Configure AWS Credentials + - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} + role-to-assume: ${{ env.AWS_ROLE_ARN }} + role-session-name: github-actions-${{ github.run_id }} + aws-region: ${{ env.AWS_REGION }} - name: Deploy to S3 (validate & upload) shell: bash @@ -45,21 +51,20 @@ jobs: test -f dist/index.html - BUCKET="${{ secrets.AWS_BUCKET_NAME }}" - [[ -n "$BUCKET" ]] - [[ "$BUCKET" != s3://* ]] + [[ -n "${S3_BUCKET}" ]] + [[ "${S3_BUCKET}" != s3://* ]] - aws s3 cp "dist" "s3://$BUCKET" \ + aws s3 cp "dist" "s3://${S3_BUCKET}" \ --recursive \ --exclude "index.html" \ --cache-control "public, max-age=31536000, immutable" - aws s3 cp "dist/index.html" "s3://$BUCKET/index.html" \ + aws s3 cp "dist/index.html" "s3://${S3_BUCKET}/index.html" \ --cache-control "no-cache, no-store, must-revalidate" \ --content-type "text/html" - name: Invalidate CloudFront cache (HTML only) run: | aws cloudfront create-invalidation \ - --distribution-id ${{ secrets.AWS_DISTRIBUTION_ID }} \ - --paths "/index.html" \ No newline at end of file + --distribution-id "${CF_DISTRIBUTION_ID}" \ + --paths "/index.html" "/assets/*" "/static/*" \ No newline at end of file diff --git a/src/api/projectMember.ts b/src/api/projectMember.ts new file mode 100644 index 0000000..b274f31 --- /dev/null +++ b/src/api/projectMember.ts @@ -0,0 +1,9 @@ +import axios from 'axios'; +import { BASE_URL, PROJECTS_MEMBER_URL } from '../constants/endpoint'; + +export const getProjectMember = async (projectId: number) => { + const { data } = await axios.get( + `${BASE_URL}${PROJECTS_MEMBER_URL(projectId)}`, + ); + return data.payload; +}; diff --git a/src/components/common/Button/OtherIconButton.tsx b/src/components/common/Button/OtherIconButton.tsx index c9af1c5..b32977c 100644 --- a/src/components/common/Button/OtherIconButton.tsx +++ b/src/components/common/Button/OtherIconButton.tsx @@ -88,7 +88,7 @@ export const BookmarkButton = ({ isBookmarked, disabled, }: BookmarkButtonProps) => { - // const updateBookmark = usePostBookmarkQuery(projectId); + const updateBookmark = usePostBookmarkQuery(projectId!); const [isActive, setIsActive] = useState(isBookmarked); const [isLoginSuggestionModalOpen, setIsLoginSuggestionModalOpen] = useState(false); @@ -101,17 +101,19 @@ export const BookmarkButton = ({ setIsLoginSuggestionModalOpen(true); return; } - // updateBookmark.mutate(); + updateBookmark.mutate(); setIsActive((prev) => !prev); }; return ( <> { return ( ); -}; \ No newline at end of file +}; diff --git a/src/hooks/useProjectMember.ts b/src/hooks/useProjectMember.ts new file mode 100644 index 0000000..532ac2d --- /dev/null +++ b/src/hooks/useProjectMember.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getProjectMember } from '../api/projectMember'; +import { MemberPayload } from '../types/api/response/payload/member'; + +export const useProjectsMemberQuery = (projectId: number) => { + return useQuery({ + queryKey: ['projectMember', projectId], + queryFn: () => getProjectMember(projectId), + enabled: !!projectId, + staleTime: 1000 * 60 * 5, // 5분 동안 캐시 유지 + refetchOnWindowFocus: false, // 윈도우 포커스 시 재요청하지 않음 + }); +}; diff --git a/src/pages/Post.tsx b/src/pages/Post.tsx index aa05ff5..880499a 100644 --- a/src/pages/Post.tsx +++ b/src/pages/Post.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useProjectsPostDetailQuery } from '../hooks/useProjectPost'; +import { useProjectsMemberQuery } from '../hooks/useProjectMember'; import { BookmarkButton } from '../components/common/Button/OtherIconButton'; import BaseButton from '../components/common/Button/BaseButton'; import SkillIcons from '../components/SkillIcons'; import TopButton from '../components/TopButton'; -import ProfileIcon from '../assets/profile/profileIcon/ic_profile_default_circle_small.svg?react'; +import MemberProfileIcon from '../assets/profile/profileIcon/ic_profile_default_circle_medium.svg?react'; import PersonIcon from '../assets/icons/ic_person.svg?react'; import IndustryIcon from '../assets/icons/filter/ic_filter_industry_small.svg?react'; import SystemIcon from '../assets/icons/filter/ic_filter_system_small.svg?react'; @@ -18,359 +19,325 @@ import { getWaysOfWorking, getWorkPeriod, } from '../utils/createMapper'; +import SupplyModal from '../components/Modal/SupplyModal'; +import BaseProfileIcon from '../components/common/Profile/ProfileIcon/BaseProfileIcon'; +import { usePostApply } from '../hooks/useApplicants'; +import { useUser } from '../hooks/useUser'; + +interface TextAreaprops { + subject: string; + progress: string; + requirements: string; + extraTitle: string; + extraContent: string; +} const Post = () => { + const { user, fetchUser } = useUser(); + const { projectId } = useParams(); const [id, setId] = useState(0); - const { data, isLoading } = useProjectsPostDetailQuery(id); + const { data: projectData, isLoading } = useProjectsPostDetailQuery(id!); + const { data: memberData } = useProjectsMemberQuery(id!); + const { mutate } = usePostApply(Number(projectId)); + + const [isModalOpen, setIsModalOpen] = useState(false); useEffect(() => { setId(Number(projectId)); + fetchUser(); }, [projectId]); - if (isLoading) return

Loading...

; + const parseDetail = (detail: string): TextAreaprops => { + const [subject, progress, requirements, extraTitle, extraContent] = + detail?.split('___SPLIT___') || []; + + return { + subject: subject || '', + progress: progress || '', + requirements: requirements || '', + extraTitle: extraTitle || '', + extraContent: extraContent || '', + }; + }; return ( -
-
-
{data?.title}
-
-
-
- {/* 사용자 프로필 */} - {/* 서버 데이터 필요 */} - + <> +
+
+
{projectData?.title}
+
+
-
- {/* truncate 말 줄이기, max-w-[...포함한 글자 수] */} - - {/* 서버 데이터 필요 */} - 일이삼사오육칠팔구십 + {/* 사용자 프로필 */} + +
+
+ {/* truncate 말 줄이기, max-w-[...포함한 글자 수] */} + + {projectData?.user.name} + + 님, +
+ {/* 서버 데이터 필요 */} + + {getPosition(projectData?.user.position!)} - 님,
- {/* 서버 데이터 필요 */} - 백엔드
-
-
-
- {data?.created_at.slice(0, 10)} +
+
+ {projectData?.created_at.slice(0, 10)} +
-
-
- -
-
-
-
- {/* 총 구성 인원 */} -
- -
- - 총 구성 인원 - -
- {/* 서버 데이터 필요 */} - - 3/10 - +
+ {user?.id !== projectData?.user.id && ( + + )} +
+
+
+
+ {/* 총 구성 인원 */} +
+ +
- 명 + 총 구성 인원 +
+ + {projectData?.recruitments.reduce( + (sum, item) => sum + item.current_count, + 0, + )} + / + {projectData?.recruitments.reduce( + (sum, item) => + sum + item.remaining_count + item.current_count, + 0, + )} + + + 명 + +
-
- {/* 산업 분야, 진행 방식, 모집기간, 진행기간 */} -
-
-
- - 산업 분야 -
-
- {getIndustry(data?.industry ?? '')} -
-
-
-
- - 진행 방식 -
-
- {getWaysOfWorking(data?.work_way ?? '')} -
-
-
-
- - 모집 기간 -
-
- {`~ ${data?.recruitment_end_date}`} -
-
-
-
- - 진행 기간 -
-
- {getWorkPeriod(data?.work_period ?? '')} -
-
-
- {/* 모집 인원, 현재 참여중인 인원, 스킬 */} -
- {/* 모집 인원 */} -
- 모집 인원 -
- {data?.recruitments.map((data, idx) => ( -
- - {getPosition(data?.position)} - -
- - {data.remaining_count}/{data.current_count} - - - 지원 - -
-
- ))} - - {/* 모집 인원 더미 데이터 */} - - {/*
- 안드로이드 -
- - 10/10 - - - 지원 - -
-
-
- 안드로이드 -
- - 10/10 - - - 지원 - -
-
-
- 안드로이드 -
- - 10/10 - - 완료 -
+ {/* 산업 분야, 진행 방식, 모집기간, 진행기간 */} +
+
+
+ + 산업 분야
-
- 안드로이드 -
- - 10/10 - - 완료 -
+
+ {getIndustry(projectData?.industry ?? '')}
-
- 안드로이드 -
- - 10/10 - - - 마감 - -
-
*/}
-
- {/* 현재 참여중인 인원 */} - {/* 서버 데이터 필요 */} -
- - 현재 참여중인 인원 - -
-
- - iOS - - 10 +
+
+ + 진행 방식
-
- - 기획자 - - 10 +
+ {getWaysOfWorking(projectData?.work_way ?? '')}
-
- - 데브옵스 - - 10 +
+
+
+ + 모집 기간
-
- - 디자이너 - - 10 +
+ {`~ ${projectData?.recruitment_end_date}`}
-
- - 마케터 - - 10 +
+
+
+ + 진행 기간
-
- - 백엔드 - - 10 +
+ {getWorkPeriod(projectData?.work_period ?? '')}
-
- - 안드로이드 - - 10 +
+
+ {/* 모집 인원, 현재 참여중인 인원, 스킬 */} +
+ {/* 모집 인원 */} +
+ 모집 인원 +
+ {projectData?.recruitments + ?.filter((data) => data.remaining_count >= 1) + .map((data, idx) => ( +
+ + {getPosition(data?.position)} + +
+ + {data.remaining_count - data.current_count}/ + {data.remaining_count} + + {/* 지원일 경우에만! */} + { + mutate(data.position); + setIsModalOpen(true); + }} + > + 지원 + +
+
+ ))}
-
- - 프론트엔드 - - 10 +
+ {/* 현재 참여중인 인원 */} +
+ + 현재 참여중인 인원 + +
+ {projectData?.recruitments + ?.filter((data) => data.current_count >= 1) + .map((data, idx) => ( +
+ + {getPosition(data.position)} + + + {data.current_count} + +
+ ))}
-
- {/* 사용 스킬 */} -
- 사용 스킬 -
- getSkill(id)) - .filter( - (label): label is string => label !== undefined, - ) ?? [] - } - size="large" - limit={null} - /> + {/* 사용 스킬 */} +
+ 사용 스킬 +
+ getSkill(id)) + .filter( + (label): label is string => label !== undefined, + ) ?? [] + } + size="large" + limit={null} + /> +
-
- {/* 프로젝트 주제 */} - {/* 서버 데이터 필요 구분자 slice 필요 */} -
-
-
- 프로젝트 주제 -
- 안녕하세요 와글입니다. 와글은 사이드 프로젝트 모집 사이트를 - 제작중에 있습니다. 지금 모집을을 보고 계신 사이트와 동일한 - 서비스에 어쩌구 저처쩌구우우우우와글은 사이드 프로젝트 모집 - 사이트를 제작중에 있습니다. 지금 모집을을 보고 계신 사이트와 - 동일한 서비스에 어쩌구저처쩌구우우우우와글은 사이드 프로젝트 - 모집 사이트를 제작중에 있습니다. 지금 모집을을 보고 계신 - 사이트와 동일한 서비스에 어쩌구저처쩌구우우우우와글은 사이드 - 프로젝트 모집 사이트를 제작중에 있습니다. 지금 모집을을 보고 - 계신 사이트와 동일한 서비스에 어쩌구저처쩌구우우우우와글은 - 사이 드 프로젝트 모집 사이트를 제작중에 있습니다. 지금 - 모집을을 보고 계신 사이트와 동일한 서비스에 - 어쩌구저처쩌구우우우우 + {/* 프로젝트 주제 */} +
+
+
+ 프로젝트 주제 +
+ {parseDetail(projectData?.detail ?? '').subject} +
-
-
- 진행 상황 -
- 안녕하세요 와글입니다. 와글은 사이드 프로젝트 모집 사이트를 - 제작중에 있습니다. 지금 모집을을 보고 계신 사이트와 동일한 - 서비스에 어쩌구 저처쩌구우우우우와글은 사이드 프로젝트 모집 - 사이트를 제작중에 있습니다. 지금 모집을을 보고 계신 사이트와 - 동일한 서비스에 어쩌구저처쩌구우우우우와글은 사이드 프로젝트 - 모집 사이트를 제작중에 있습니다. 지금 모집을을 보고 계신 - 사이트와 동일한 서비스에 어쩌구저처쩌구우우우우와글은 사이드 - 프로젝트 모집 사이트를 제작중에 있습니다. 지금 모집을을 보고 - 계신 사이트와 동일한 서비스에 어쩌구저처쩌구우우우우와글은 - 사이 드 프로젝트 모집 사이트를 제작중에 있습니다. 지금 - 모집을을 보고 계신 사이트와 동일한 서비스에 - 어쩌구저처쩌구우우우우 +
+ 진행 상황 +
+ {parseDetail(projectData?.detail ?? '').progress} +
-
-
- 진행 과정 -
- 안녕하세요 와글입니다. 와글은 사이드 프로젝트 모집 사이트를 - 제작중에 있습니다. 지금 모집을을 보고 계신 사이트와 동일한 - 서비스에 어쩌구 저처쩌구우우우우와글은 사이드 프로젝트 모집 - 사이트를 제작중에 있습니다. 지금 모집을을 보고 계신 사이트와 - 동일한 서비스에 어쩌구저처쩌구우우우우와글은 사이드 프로젝트 - 모집 사이트를 제작중에 있습니다. 지금 모집을을 보고 계신 - 사이트와 동일한 서비스에 어쩌구저처쩌구우우우우와글은 사이드 - 프로젝트 모집 사이트를 제작중에 있습니다. 지금 모집을을 보고 - 계신 사이트와 동일한 서비스에 어쩌구저처쩌구우우우우와글은 - 사이 드 프로젝트 모집 사이트를 제작중에 있습니다. 지금 - 모집을을 보고 계신 사이트와 동일한 서비스에 - 어쩌구저처쩌구우우우우 +
+ 지원 자격 +
+ {parseDetail(projectData?.detail ?? '').requirements} +
-
-
-
현재 참여 인원 소개
-
- 프론트 : 경력 2년 어디에서 근무 어디에 관심 많음 열정만 있으면 - 같이 잘할수있음 디자이너 : 경력 2년 어디에서 근무 어디에 관심 - 많음 열정만 있으면 같이 잘할수있음 백엔드 : 경력 2년 어디에서 - 근무 어디에 관심 많음 열정만 있으면 같이 잘할수있음

- 많은 참여 부탁드리며 문의는 언제든 환영입니다!! +
+
+ {parseDetail(projectData?.detail ?? '').extraTitle} +
+
+ {parseDetail(projectData?.detail ?? '').extraContent} +
-
- {/* 현재 참여중인 팀원 정보 */} -
-
- 현재 참여중인 팀원 -
-
-
-
- {/* 아이콘, 더보기 워딩 색깔 어떻게? */} - } - className="text-black-70" - > - 더보기 - + {/* 현재 참여중인 팀원 정보 */} +
+
+ 현재 참여중인 팀원 +
+
+
+ {memberData?.map((data, idx) => ( +
+
+
+ +
+
+ + {data.name} + +
+ + {getPosition(data.position)} + +
+ + {data.year_count}년차 이상 + +
+
+
+
+ ))} +
+
+ } + className="text-black-70" + > + 더보기 + +
+
- -
+ setIsModalOpen(false)} + /> + ); }; diff --git a/src/types/api/response/payload/member.ts b/src/types/api/response/payload/member.ts new file mode 100644 index 0000000..82cb33f --- /dev/null +++ b/src/types/api/response/payload/member.ts @@ -0,0 +1,31 @@ +import { DaysOfWeekType } from '../../../constants/daysOfWeek.type'; +import { IndustryType } from '../../../constants/industry.type'; +import { IntroductionType } from '../../../constants/introduction.type'; +import { PortfoliosType } from '../../../constants/portfolioType.type'; +import { PositionType } from '../../../constants/position.type'; +import { SidoType } from '../../../constants/sido.type'; +import { SkillType } from '../../../constants/skill.type'; +import { WorkTimeType } from '../../../constants/workTime.type'; +import { WorkWayType } from '../../../constants/workWay.type'; + +export interface MemberPayload { + id: string; + provider: string; + provider_id: string; + profile_img_url: string; + name: string; + email: string; + position: PositionType; + year_count: number; + industries: IndustryType[]; + skills: SkillType[]; + days_of_week: DaysOfWeekType[]; + preferred_work_time: WorkTimeType; + preferred_work_way: WorkWayType; + preferred_sido: SidoType; + introductions: IntroductionType; + detail: string; + portfolios: PortfoliosType[]; + created_at: string; + updated_at: string; +}