diff --git a/src/App.tsx b/src/App.tsx index a3e8651..d1bbb8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -84,6 +84,7 @@ const router = (isLoggedIn: boolean) => } /> } /> + } /> } /> ) diff --git a/src/api/ChallengeApi.ts b/src/api/ChallengeApi.ts new file mode 100644 index 0000000..a487e3d --- /dev/null +++ b/src/api/ChallengeApi.ts @@ -0,0 +1,100 @@ +import { Challenge, ChallengeResponse } from '../types/ChallengeType'; +import { axiosInstance } from '../utils/apiConfig'; + +// * 챌린지 create +export const createChallenge = async (data: FormData): Promise => { + try { + const response = await axiosInstance.post('/challenges', data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + console.log(response.data); + return ''; + } catch (error) { + console.error('Error fetching data:', error); + // return null; + } +}; + +// * 챌린지 update +export const patchChallenge = async (id: string, data: FormData): Promise => { + try { + const response = await axiosInstance.patch(`/challenges/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + // console.log(response.data); + return response.data.data.challengeId; + } catch (error) { + console.error('Error fetching data:', error); + // return null; + } +}; + +// * 챌린지 get +export const getSearchChallenge = async ( + keyword: string | null, + category: string | null, + page: number, + size: number +): Promise => { + try { + console.log(keyword, category, '로 검색할게요'); + const response = await axiosInstance.get(`/challenges/search`, { + params: { + category: category || '', // 빈 문자열은 전체 검색 + keyword: keyword || '', + page: page, + size: size, + }, + }); + console.log('챌린지 전체 데이터', response.data); + return response.data.data; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 챌린지 상세보기 get +export const getChallengeDetail = async (challengeId: string): Promise => { + try { + const response = await axiosInstance.get(`/challenges/${challengeId}`); + // console.log(response.data.data); + + return response.data.data; + } catch (error) { + console.error('Error fetching data:', error); + return null; + } +}; + +// * 챌린지 삭제 +export const deleteChallenge = async (id: string): Promise => { + try { + const response = await axiosInstance.delete(`/challenges/${id}`); + + console.log(response); + } catch (error) { + console.log('error'); + } +}; + +// * 챌린지 참여 +export const joinChallenge = async (challengeId: string, dashboardId: string): Promise => { + try { + const response = await axiosInstance.post(`/challenges/${challengeId}/${dashboardId}`); + console.log(response.data); + } catch (error) { + console.error('Error fetching data:', error); + } +}; + +// * 챌린지 탈퇴 +export const withdrawChallenge = async (id: string): Promise => { + try { + const response = await axiosInstance.delete(`/challenges/${id}/withdraw`); + + console.log(response); + } catch (error) { + console.log('error'); + } +}; diff --git a/src/components/ChallengeCard.tsx b/src/components/ChallengeCard.tsx index 00ff79b..b2018e8 100644 --- a/src/components/ChallengeCard.tsx +++ b/src/components/ChallengeCard.tsx @@ -1,14 +1,58 @@ import { useNavigate } from 'react-router-dom'; import * as S from '../styles/ChallengeStyled'; +import { + Challenge, + ChallengeCycle, + ChallengeCycleDetail_Monthly, + ChallengeCycleDetail_Weekly, +} from '../types/ChallengeType'; +import { useState, useEffect } from 'react'; +import defaultImg from '../img/default.png'; -const ChallengeCard = () => { +const ChallengeCard = ({ challenge }: { challenge: Challenge }) => { const navigate = useNavigate(); + const [imageSrc, setImageSrc] = useState(undefined); + + useEffect(() => { + if (challenge.representImage instanceof File) { + // File 타입일 경우 URL로 변환 + const objectUrl = URL.createObjectURL(challenge.representImage); + setImageSrc(objectUrl); + + // 메모리 누수를 방지하기 위해 컴포넌트가 언마운트될 때 URL 해제 + return () => URL.revokeObjectURL(objectUrl); + } else { + // string 타입일 경우 그대로 사용 + setImageSrc(challenge.representImage); + } + }, [challenge.representImage]); return ( - navigate(1)}> - - 챌린지명 - 참여 인원수 + navigate(`${challenge.challengeId}`)}> + {imageSrc ? : } + {challenge.title} + + {challenge.cycle + ? ChallengeCycle[challenge.cycle as keyof typeof ChallengeCycle] + : '알 수 없는 주기'}{' '} + {challenge.cycle === 'WEEKLY' && + Array.isArray(challenge.cycleDetails) && + challenge.cycleDetails + .map( + (detail: string) => + ChallengeCycleDetail_Weekly[detail as keyof typeof ChallengeCycleDetail_Weekly] + ) + .join(', ')} + {challenge.cycle === 'MONTHLY' && + Array.isArray(challenge.cycleDetails) && + challenge.cycleDetails + .map( + (detail: string) => + ChallengeCycleDetail_Monthly[detail as keyof typeof ChallengeCycleDetail_Monthly] + + '일' + ) + .join(', ')} + ); }; diff --git a/src/components/JoinChallengeModal.tsx b/src/components/JoinChallengeModal.tsx new file mode 100644 index 0000000..7e97fcd --- /dev/null +++ b/src/components/JoinChallengeModal.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; + +import ErrorIcon from '../img/error.png'; +import Flex from './Flex'; +import { + StyledModal, + customStyles, + ErrorImg, + SubTitle, + Title, + BtnYes, + BtnNo, +} from '../styles/ModalStyled'; +import * as S from '../styles/ChallengeStyled'; +import { searchPersonalDashBoard } from '../api/BoardApi'; +import { useQuery } from '@tanstack/react-query'; +import { DashboardItem } from '../types/PersonalDashBoard'; +import { joinChallenge } from '../api/ChallengeApi'; +import { useNavigate } from 'react-router-dom'; + +export interface CustomModalProps { + challengeId: string; + onNoClick: () => void; + fetchedData: () => void; +} + +const JoinChallengeModal = ({ challengeId, onNoClick, fetchedData }: CustomModalProps) => { + // 데이터 가져오기 + const { data } = useQuery({ + queryKey: ['personalDashboard'], + queryFn: searchPersonalDashBoard, + }); + const [selectDashboard, setSelectDashboard] = useState(''); + + // * 데이터가 로드되면 첫 번째 대시보드 ID를 기본값으로 설정 + useEffect(() => { + if ( + data?.data.personalDashboardListResDto && + data.data.personalDashboardListResDto.length > 0 + ) { + const firstDashboardId = data.data.personalDashboardListResDto[0].dashboardId; + if (firstDashboardId !== null && firstDashboardId !== undefined) { + setSelectDashboard(String(firstDashboardId)); // 첫 번째 값을 기본 선택값으로 설정 + } + } + }, [data]); + + // * 선택된 개인 대시보드 (챌린지 추가할 대시보드) + const handleSelectChange = (event: React.ChangeEvent) => { + setSelectDashboard(event.target.value); + }; + + // * 챌린지 참여 api + const submitJoin = async () => { + try { + await joinChallenge(challengeId, selectDashboard); // 챌린지 참여 + fetchedData(); // 참여 후 데이터 다시 불러오기 + onNoClick(); // 모달 닫기 + } catch (error) { + console.error('Error joining challenge:', error); + } + }; + + return ( + + {/* */} + + 챌린지 참여하기 + + 챌린지를 추가할 대시보드를 선택해주세요. + + + + {data?.data.personalDashboardListResDto?.map((item: DashboardItem) => ( + + ))} + + + + 취소 + 참여 + + + ); +}; + +export default JoinChallengeModal; diff --git a/src/img/default.png b/src/img/default.png new file mode 100644 index 0000000..a6a15c0 Binary files /dev/null and b/src/img/default.png differ diff --git a/src/pages/ChallengeCommunityPage.tsx b/src/pages/ChallengeCommunityPage.tsx index c96ccf2..d4e200a 100644 --- a/src/pages/ChallengeCommunityPage.tsx +++ b/src/pages/ChallengeCommunityPage.tsx @@ -4,26 +4,71 @@ import addbutton from '../img/addbutton.png'; import leftarrow from '../img/leftarrow.png'; import Flex from '../components/Flex'; import Pagination from '../components/CustomPagination'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import ChallengeCard from '../components/ChallengeCard'; import { Link } from 'react-router-dom'; +import { Challenge, ChallengeCategory, ChallengeResponse } from '../types/ChallengeType'; +import { getSearchChallenge } from '../api/ChallengeApi'; const ChallengeCommunityPage = () => { const [count, setCount] = useState(1); // 총 페이지 수 const [page, setPage] = useState(1); // 현재 페이지 + const [pageInfo, setPageInfo] = useState({ + currentPage: 0, + totalPages: 1, + totalItems: 0, + }); + const [selectedCategory, setSelectedCategory] = useState(''); + const [keyword, setKeyword] = useState(''); + const [challenges, setChallenges] = useState(); - // 페이지네이션 페이지 변경 감지 함수 + // * 페이지네이션 페이지 변경 감지 함수 const handleChangePage = (event: React.ChangeEvent, value: number) => { setPage(value); // 페이지 변경 시 현재 페이지 상태 업데이트 + setPageInfo(prevPageInfo => ({ + ...prevPageInfo, // 기존 pageInfo 값을 유지 + currentPage: value - 1, + })); }; + // * 카테고리로 선택하여 검색 + const handleCategoryClick = async (category: string) => { + setSelectedCategory(category); + setPage(1); + setPageInfo(prevPageInfo => ({ + ...prevPageInfo, // 기존 pageInfo 값을 유지 + currentPage: 0, + })); + }; + + // * 검색어 변경 함수 + // ? debounce 설정? + const handleInput = (e: React.ChangeEvent) => { + setKeyword(e.target.value); + }; + + // * 팀 문서 카테고리별 검색 get + const fetchDocumentData = async () => { + const response = await getSearchChallenge(keyword, selectedCategory, pageInfo?.currentPage, 10); + + if (response) { + console.log('이만큼 받아옴!', response); + setChallenges(response.challengeInfoResDto); + setPageInfo(response.pageInfoResDto); + } + }; + + useEffect(() => { + fetchDocumentData(); + }, [pageInfo.currentPage, location.pathname, selectedCategory, keyword]); // 페이지가 변경될 때와, 데이터가 변경되었을 때 (즉 라우터가 변경되었을 때) 리렌더링 + return ( - + {/* */} 챌린지 다른 참여자와 함께 이뤄나가요. @@ -35,40 +80,66 @@ const ChallengeCommunityPage = () => { - 카테고리 1 - 카테고리 2 - 카테고리 3 - 카테고리 1 - 카테고리 2 - 카테고리 3 - 카테고리 1 - 카테고리 2 - 카테고리 3 - 카테고리 1 - 카테고리 2 - 카테고리 3 - 카테고리 1 - 카테고리 2 - 카테고리 3 + handleCategoryClick('')} isSelected={selectedCategory === ''}> + 전체 + + {Object.entries(ChallengeCategory).map(([key, value]) => ( + handleCategoryClick(key)} + isSelected={selectedCategory === key} + > + {value} + + ))} -
- - 검색 -
+ + + + + + + + {/* 검색 */} +
- {/* 반응형 때문에 몇 개 렌더링 되는지 확인해야 함. 아니면 css 자체를 수정해야 함. */} - - - - - - + + + {challenges?.map((challenge, index) => ( + + ))} + + - +
diff --git a/src/pages/ChallengeDetailPage.tsx b/src/pages/ChallengeDetailPage.tsx index fa09a29..e734e16 100644 --- a/src/pages/ChallengeDetailPage.tsx +++ b/src/pages/ChallengeDetailPage.tsx @@ -4,10 +4,104 @@ import * as S from '../styles/ChallengeStyled'; import leftarrow from '../img/leftarrow.png'; import editBtn from '../img/edit.png'; import delBtn from '../img/delete2.png'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { deleteChallenge, getChallengeDetail, withdrawChallenge } from '../api/ChallengeApi'; +import { + Challenge, + ChallengeCategory, + ChallengeCycle, + ChallengeCycleDetail_Monthly, + ChallengeCycleDetail_Weekly, +} from '../types/ChallengeType'; +import defaultImg from '../img/default.png'; +import CustomModal from '../components/CustomModal'; +import useModal from '../hooks/useModal'; +import JoinChallengeModal from '../components/JoinChallengeModal'; const ChallengeDetailPage = () => { const navigate = useNavigate(); + const location = useLocation(); + const pathname = location.pathname; + const challengeId = pathname.split('/').pop(); + + const [challengeData, setChallengeData] = useState({ representImage: undefined }); + const [imageSrc, setImageSrc] = useState(undefined); + const { isModalOpen, openModal, handleYesClick, handleNoClick } = useModal(); // 모달창 관련 훅 호출 + const [join, setJoin] = useState(false); + const [isDelModalOpen, setIsDelModalOpen] = useState(false); + const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); + + // * 챌린지 상세보기 데이터 받아오기 + const fetchedData = async () => { + if (challengeId) { + const res = await getChallengeDetail(challengeId); + if (res) { + setChallengeData(res); + } + } + }; + + useEffect(() => { + fetchedData(); + }, []); + + // * 파일 이미지 url을 string으로 변환 + useEffect(() => { + if (challengeData.representImage instanceof File) { + // File 타입일 경우 URL로 변환 + const objectUrl = URL.createObjectURL(challengeData.representImage); + setImageSrc(objectUrl); + + // 메모리 누수를 방지하기 위해 컴포넌트가 언마운트될 때 URL 해제 + return () => URL.revokeObjectURL(objectUrl); + } else { + // string 타입일 경우 그대로 사용 + setImageSrc(challengeData.representImage); + } + }, [challengeData]); + + // * 날짜 포맷 변경 + const formatDate = (dateString: string) => { + const [year, month, day] = dateString.split('-').map(part => part.trim()); + + return `${year}년 ${month}월 ${day}일`; + }; + + // * 챌린지 삭제 api + const delChallenge = async () => { + if (challengeId) { + await deleteChallenge(challengeId); + navigate('/challenge'); + } + }; + + // * 챌린지 삭제 모달창 + const submitDelChallenge = () => { + setIsDelModalOpen(true); + const handleModalClose = () => setIsDelModalOpen(false); + openModal('yes', delChallenge, handleModalClose); // yes 버튼이 눌릴 때만 대시보드 삭제 api 요청 + }; + + // * 챌린지 참여 + const joinChallenge = () => { + setJoin(!join); + }; + + // * 챌린지 탈퇴 + const cancelChallenge = async () => { + if (challengeId) { + await withdrawChallenge(challengeId); + fetchedData(); + } + }; + + // * 챌린지 탈퇴 모달창 + const submitWithdrawChallenge = () => { + setIsWithdrawModalOpen(true); + const isWithdrawModalOpen = () => setIsWithdrawModalOpen(false); + openModal('yes', cancelChallenge, isWithdrawModalOpen); + }; return ( @@ -17,41 +111,89 @@ const ChallengeDetailPage = () => { navigate(-1)} /> {/* 뒤로가기 버튼 */} - 카테고리 - 챌린지 제목 + + {challengeData.category && + ChallengeCategory[challengeData.category as keyof typeof ChallengeCategory]} + + {challengeData.title} - {/* 생성자는 수정 / 삭제 버튼 */} - {/* - - - */} - - {/* 사용자는 참여하기 or 탈퇴하기 버튼 */} - 참여하기 - {/* 탈퇴하기 */} + + {/* 생성자도 참여하기 버튼을 통해 챌린지에 참여 */} + {challengeData.isAuthor && ( + + { + navigate(`/challenge/create/${challengeId}`); + }} + /> + + + )} + + {/* 참여하기 or 탈퇴하기 버튼 */} + {challengeData.isParticipant ? ( + 탈퇴하기 + ) : ( + 참여하기 + )} + {join && challengeId && ( + + )} + 챌린지 정보 - + - 챌린지 제목 + {challengeData.title} + + {`${formatDate(challengeData.startDate ?? '')} ~ ${formatDate(challengeData.endDate ?? '')}`} + -

- 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 - 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 - 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 가나다라 -

+

{challengeData.contents}

- 1422명 참여 - 매주 수요일 + + {challengeData?.participantCount}명 참여 중 + + + {challengeData.cycle + ? ChallengeCycle[challengeData.cycle as keyof typeof ChallengeCycle] + : '알 수 없는 주기'}{' '} + {challengeData.cycle === 'WEEKLY' && + Array.isArray(challengeData.cycleDetails) && + challengeData.cycleDetails + .map( + (detail: string) => + ChallengeCycleDetail_Weekly[ + detail as keyof typeof ChallengeCycleDetail_Weekly + ] + ) + .join(', ')} + {challengeData.cycle === 'MONTHLY' && + Array.isArray(challengeData.cycleDetails) && + challengeData.cycleDetails + .map( + (detail: string) => + ChallengeCycleDetail_Monthly[ + detail as keyof typeof ChallengeCycleDetail_Monthly + ] + '일' + ) + .join(', ')} +
@@ -59,15 +201,39 @@ const ChallengeDetailPage = () => { 블록 미리보기 -

챌린지 제목 챌린지

-

매주 수요매주 수요매주 수요매주 수요매주 수요일

+

{challengeData.title}

+

+ {challengeData.cycle + ? ChallengeCycle[challengeData.cycle as keyof typeof ChallengeCycle] + : '알 수 없는 주기'}{' '} + {challengeData.cycle === 'WEEKLY' && + Array.isArray(challengeData.cycleDetails) && + challengeData.cycleDetails + .map( + (detail: string) => + ChallengeCycleDetail_Weekly[ + detail as keyof typeof ChallengeCycleDetail_Weekly + ] + ) + .join(', ')} + {challengeData.cycle === 'MONTHLY' && + Array.isArray(challengeData.cycleDetails) && + challengeData.cycleDetails + .map( + (detail: string) => + ChallengeCycleDetail_Monthly[ + detail as keyof typeof ChallengeCycleDetail_Monthly + ] + '일' + ) + .join(', ')} +

챌린지 장 - - 이름 + + {challengeData.authorName}
@@ -75,22 +241,40 @@ const ChallengeDetailPage = () => {
실시간 완료 + + {/* 사용자 프로필 누르면 프로필 조회 가능해야함 */} - - - 이름이름 - - - - - 이름이름 - - - - - 이름이름 - + {challengeData?.completedMembers && challengeData.completedMembers.length > 0 ? ( + challengeData.completedMembers.map((member, index) => ( + + + {member.nickname} + + )) + ) : ( + 챌린지를 완료한 첫 번째 주인공이 되어보세요! + )} + + {/* 삭제 동의 모달창 */} + {isModalOpen && isDelModalOpen && ( + + )} + + {/* 탈퇴 동의 모달창 */} + {isModalOpen && isWithdrawModalOpen && ( + + )}
); diff --git a/src/pages/CreateChallengePage.tsx b/src/pages/CreateChallengePage.tsx index f846659..60275f4 100644 --- a/src/pages/CreateChallengePage.tsx +++ b/src/pages/CreateChallengePage.tsx @@ -1,32 +1,234 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Flex from '../components/Flex'; import Navbar from '../components/Navbar'; import * as S from '../styles/ChallengeStyled'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; +import { + ChallengeCategory, + ChallengeCycleDetail_Monthly, + ChallengeCycleDetail_Weekly, +} from '../types/ChallengeType'; +import { Challenge } from '../types/ChallengeType'; +import { createChallenge, getChallengeDetail, patchChallenge } from '../api/ChallengeApi'; +import { stringify } from 'querystring'; +import { useLocation, useNavigate } from 'react-router-dom'; +import useModal from '../hooks/useModal'; +import CustomModal from '../components/CustomModal'; + +// * 날짜 포맷 설정 함수 +const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; const CreateChallengePage = () => { - // 더미 데이터: 현재 날짜를 기본값으로 설정 - const [startDate, setStartDate] = useState(new Date()); + const location = useLocation(); + let challengeId = location.pathname.split('/').pop() || null; + if (challengeId === 'create') challengeId = null; + + const { isModalOpen, openModal, handleYesClick, handleNoClick } = useModal(); // 모달창 관련 훅 호출 + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + challengeId: '0', + title: '', + contents: '', + category: 'HEALTH_AND_FITNESS', + cycle: 'DAILY', + cycleDetails: [], + startDate: formatDate(new Date()), + endDate: formatDate(new Date()), + representImage: '', + authorName: '', + authorProfileImage: '', + blockName: '', + }); + const [isHovering, setIsHovering] = useState(false); // 미리보기 상태 추가 + + // * 챌린지 수정시 챌린지 상세 데이터 불러오기 + const fetchData = async () => { + if (challengeId) { + const data = await getChallengeDetail(challengeId); + setFormData({ + challengeId: data?.challengeId ?? '0', + title: data?.title ?? '', + contents: data?.contents ?? '', + category: data?.category ?? 'HEALTH_AND_FITNESS', + cycle: data?.cycle ?? 'DAILY', + cycleDetails: data?.cycleDetails ?? [], + startDate: data?.startDate ?? formatDate(new Date()), + endDate: data?.endDate ?? formatDate(new Date()), + representImage: + data?.representImage instanceof File + ? URL.createObjectURL(data.representImage) + : (data?.representImage ?? ''), // 파일일 때만 URL 생성 + authorName: data?.authorName ?? '', + authorProfileImage: data?.authorProfileImage ?? '', + blockName: data?.blockName ?? '', + }); + } + }; - // 주기 선택 상태 - const [selectedTerm, setSelectedTerm] = useState('매일'); + useEffect(() => { + fetchData(); + }, [challengeId]); - // 주기 선택 핸들러 + // * 폼 데이터 변경 핸들러 + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + // * 주기 선택 핸들러 const handleTermChange = (term: string) => { - setSelectedTerm(term); + setFormData(prevData => ({ + ...prevData, + cycle: term, // formData.cycle 업데이트 + cycleDetails: [], + })); + }; + + // * 이미지 파일 선택 핸들러 + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; // 파일이 없을 수도 있으므로 `undefined` 가능성 있음 + + if (file) { + setFormData(prev => ({ ...prev, representImage: file })); + } + }; + + // * 날짜 변경 핸들러 + const handleDateChange = (date: Date | null) => { + setFormData(prev => ({ + ...prev, + endDate: date ? formatDate(date) : '', // null일 경우 빈 문자열로 설정 + })); }; - // 날짜 변경 핸들러 - const handleDateChange = (date: Date | null, type: string) => { - if (type === 'start') { - setStartDate(date); + // * 주기 - '매달'의 렌더링 관련 함수 + // 1부터 31까지의 날짜를 배열로 생성 + const daysInMonth = Array.from({ length: 31 }, (_, i) => i + 1); + // 배열을 지정된 크기로 나누는 함수 + const chunkArray = (array: number[], size: number) => { + const result: number[][] = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); } + return result; + }; + const chunks = chunkArray(daysInMonth, 7); + + // * 주기 디테일 변경 핸들러 + const handleCycleDetailClick = (day: number, type: 'day' | 'week') => { + let dayKey: string | undefined; + + if (type === 'week') { + // 주간 사이클의 키를 찾음 + dayKey = Object.keys(ChallengeCycleDetail_Weekly).find( + key => + ChallengeCycleDetail_Weekly[key as keyof typeof ChallengeCycleDetail_Weekly] === + Object.values(ChallengeCycleDetail_Weekly)[day - 1] + ); + } else if (type === 'day') { + // 월간 사이클의 키를 찾음 + dayKey = Object.keys(ChallengeCycleDetail_Monthly).find( + key => + ChallengeCycleDetail_Monthly[key as keyof typeof ChallengeCycleDetail_Monthly] === + day.toString() + ); + } + + if (!dayKey) return; + + setFormData(prevData => { + // `cycleDetails`가 문자열 배열인지 확인 + const currentCycleDetails = Array.isArray(prevData.cycleDetails) ? prevData.cycleDetails : []; + + // 선택된 상태인지 확인하고 선택 토글 + const isSelected = currentCycleDetails.includes(dayKey); + const newCycleDetails: string[] = isSelected + ? currentCycleDetails.filter(d => d !== dayKey) // 선택 해제 + : [...currentCycleDetails, dayKey]; // 선택 추가 + + return { + ...prevData, + cycleDetails: newCycleDetails, + }; + }); }; - // 더미 parseDate 함수 - const parseDate = (dateString: string): Date => { - return new Date(dateString); + // * ChallengeCycleDetail_Monthly의 값에서 키를 찾는 함수 + const getDayKey = (day: number): string | undefined => { + return Object.keys(ChallengeCycleDetail_Monthly).find( + key => + ChallengeCycleDetail_Monthly[key as keyof typeof ChallengeCycleDetail_Monthly] === + day.toString() + ); + }; + + // * isSelected 상태를 결정하는 함수 + const isDaySelected = (day: number): boolean => { + const dayKey = getDayKey(day); + return dayKey ? (formData.cycleDetails?.includes(dayKey) ?? false) : false; + }; + + // * 제출시 빈 칸이 있나 확인하는 함수 (있다면 true, 대표 이미지는 선택이라 제외) + const validateFormData = (formData: Challenge): boolean => { + return Object.entries(formData).some(([key, value]) => { + // representImage, authorName, authorProfileImage 필드는 검사에서 제외 + if (key === 'representImage' || key === 'authorName' || key === 'authorProfileImage') + return false; + return value === ''; + }); + }; + + // * 폼 제출 핸들러 + const handleSubmit = async () => { + console.log(formData); + // 빈 작성란이 있으면 모달창 띄우기 + if (validateFormData(formData)) { + openModal('normal'); // 모달 띄우기 (yes/no 모달) + return; // 폼 제출 중단 + } + + // 폼 데이터 생성 + const data = new FormData(); + + const challengeSaveReqDto = { + title: formData.title, + contents: formData.contents, + category: formData.category, + cycle: formData.cycle, + cycleDetails: formData.cycleDetails, + endDate: formData.endDate, + blockName: formData.blockName, + }; + + // JSON 데이터를 Blob으로 변환 후 FormData에 추가 + const jsonBlob = new Blob([JSON.stringify(challengeSaveReqDto)], { + type: 'application/json', + }); + data.append('challengeSaveReqDto', jsonBlob); + + // 이미지 파일 추가 + if (formData.representImage instanceof File) { + data.append('representImage', formData.representImage); + } + + try { + const responseChallengeId = challengeId + ? await patchChallenge(challengeId, data) // 기존 대시보드 수정 + : await createChallenge(data); // 새 대시보드 생성 + + // 챌린지 페이지로 이동 + navigate(responseChallengeId ? `/challenge/${responseChallengeId}` : `/challenge`); + } catch (error) { + console.error('챌린지 생성 및 수정 중 오류 발생!', error); + } }; return ( @@ -34,7 +236,7 @@ const CreateChallengePage = () => { - 챌린지 생성 + 챌린지 {challengeId ? '수정' : '생성'} 챌린지 팀원 모집 게시글 @@ -42,41 +244,76 @@ const CreateChallengePage = () => { 설명 카테고리 - - - - - + + {Object.entries(ChallengeCategory).map(([key, value]) => ( + + ))} - 이미지 - - + 대표 이미지 + setIsHovering(true)} // 마우스 오버 시 상태 업데이트 + onMouseLeave={() => setIsHovering(false)} + > + + {formData.representImage && ( + + {/*

미리보기

*/} + 챌린지 이미지 +
+ )}
@@ -85,46 +322,67 @@ const CreateChallengePage = () => { 제목 주기 - handleTermChange('매일')}>매일 - handleTermChange('매 주')}>매 주 - handleTermChange('매 월')}>매 월 + handleTermChange('DAILY')} + isSelected={formData.cycle === 'DAILY'} // 선택된 상태 확인 + > + 매일 + + handleTermChange('WEEKLY')} + isSelected={formData.cycle === 'WEEKLY'} // 선택된 상태 확인 + > + 매주 + + handleTermChange('MONTHLY')} + isSelected={formData.cycle === 'MONTHLY'} // 선택된 상태 확인 + > + 매월 + - {selectedTerm === '매일' && } + {formData.cycle === 'DAILY' && } - {selectedTerm === '매 주' && ( + {formData.cycle === 'WEEKLY' && ( - - - - - - - + {Object.entries(ChallengeCycleDetail_Weekly).map(([key, day], index) => ( + handleCycleDetailClick(index + 1, 'week')} + isSelected={formData.cycleDetails?.includes(key) ?? false} // 키를 사용하여 선택 상태 확인 + > + {day} + + ))} )} - {selectedTerm === '매 월' && ( - - - handleDateChange(date, 'start')} - showTimeSelect - dateFormat="MM.dd" - /> -

에 반복

-
-
+ {formData.cycle === 'MONTHLY' && ( + + {chunks.map((week, index) => ( + + {week.map(day => ( + handleCycleDetailClick(day, 'day')} + isSelected={isDaySelected(day)} + > + {day} + + ))} + + ))} + )}
@@ -133,8 +391,8 @@ const CreateChallengePage = () => { handleDateChange(date, 'start')} + selected={formData.endDate ? new Date(formData.endDate) : null} + onChange={handleDateChange} // 날짜 선택 시 호출될 핸들러 showTimeSelect dateFormat="yyyy.MM.dd" /> @@ -143,8 +401,18 @@ const CreateChallengePage = () => { - 챌린지 생성 + 챌린지 {challengeId ? '수정' : '생성'}
+ + {/* 작성되지 않은 부분이 있으면 모달창으로 알림 */} + {isModalOpen && ( + + )}
); diff --git a/src/styles/ChallengeStyled.tsx b/src/styles/ChallengeStyled.tsx index f4b8645..3c96055 100644 --- a/src/styles/ChallengeStyled.tsx +++ b/src/styles/ChallengeStyled.tsx @@ -54,13 +54,14 @@ export const CategoriesContainer = styled.p` overflow-y: scroll; `; -export const Category = styled.p` +export const Category = styled.p<{ isSelected: boolean }>` width: fit-content; padding: 0.5rem 1rem; border-radius: 2rem; margin-right: 0.7rem; white-space: nowrap; - font-size: 0.8rem; + font-size: 0.9rem; + color: ${theme.color.gray}; background-color: ${theme.color.stroke2}; cursor: pointer; @@ -68,10 +69,16 @@ export const Category = styled.p` linear-gradient(white, white) padding-box, linear-gradient(45deg, rgba(76, 140, 255, 0.5), rgba(152, 71, 255, 0.5)) border-box; border: 3px solid transparent; + &:hover { background: linear-gradient(45deg, ${theme.color.main}, ${theme.color.main2}) border-box; color: ${theme.color.white}; } + + background: ${props => + props.isSelected && + `linear-gradient(45deg, ${theme.color.main}, ${theme.color.main2}) border-box`}; + color: ${props => (props.isSelected ? `${theme.color.white}` : `${theme.color.gray}`)}; `; export const SearchContainer = styled.div` @@ -81,9 +88,11 @@ export const SearchContainer = styled.div` align-items: center; `; -export const SearchBar = styled.div``; +export const SearchBar = styled.div` + display: flex; + justify-content: center; + align-items: center; -export const Input = styled.input` width: 30rem; padding: 0.8rem 2rem; font-size: 0.8rem; @@ -92,6 +101,23 @@ export const Input = styled.input` &:focus { outline: none; } + + svg { + margin-right: 1rem; + } +`; + +export const Input = styled.input` + width: 100%; + /* width: 30rem; + padding: 0.8rem 2rem; + font-size: 0.8rem; + border-radius: 5rem; + border: 1px solid ${theme.color.lightGray}; */ + border: none; + &:focus { + outline: none; + } `; export const Button = styled.button` @@ -106,14 +132,14 @@ export const Button = styled.button` `; export const ChallengeContainer = styled.div` - width: 100%; + width: 80%; margin-top: 2rem; - height: 63%; - display: flex; - flex-wrap: wrap; /* 아이템이 컨테이너의 너비를 초과하면 자동으로 줄바꿈 */ - gap: 1rem; /* 아이템 간의 간격 */ - justify-content: center; /* 아이템들을 중앙 정렬 */ - overflow: hidden; + display: grid; + gap: 2rem 0.5rem; + + grid-template-columns: repeat(5, 1fr); /* 각 줄에 5개의 열 */ + grid-template-rows: repeat(2, auto); /* 2줄로 고정 */ + max-width: 100vw; `; export const ChallengeComponent = styled.div` @@ -124,9 +150,9 @@ export const ChallengeComponent = styled.div` cursor: pointer; `; -export const ChallengeImg = styled.div` - width: 9rem; - height: 9rem; +export const ChallengeImg = styled.img` + width: 9vw; + height: 9vw; border-radius: 50%; background-color: ${theme.color.lightGray}; `; @@ -146,7 +172,7 @@ export const ChallengeHeadCount = styled.p` export const PaginationWrapper = styled.div` width: 100%; - margin-top: 1rem; + margin-top: 2rem; display: flex; justify-content: center; `; @@ -163,7 +189,7 @@ export const CreateDashBoardContainer = styled.section` `; export const CreateDashBoardModal = styled.div` - padding: 5rem; + padding: 4rem 5rem; border-radius: 1rem; border: 1px solid ${theme.color.stroke2}; box-shadow: ${theme.boxShadow.default}; @@ -194,13 +220,20 @@ export const Label = styled.label` `; export const FileLabel = styled.label` - width: 13.8rem; + /* width: 13.8rem; */ + z-index: 1; + width: 6rem; + height: 6rem; padding: 0.5rem 1rem; font-size: 0.9rem; border-radius: 0.3rem; border: 1px solid ${theme.color.stroke2}; color: ${theme.color.gray}; overflow-x: scroll; + cursor: pointer; + input[type='file'] { + display: none; + } input[type='file']::file-selector-button { padding: 0.2rem 0.5rem; background: #fff; @@ -263,6 +296,19 @@ export const Select = styled.select` } `; +export const JoinSelect = styled.select` + padding: 0.5rem 1rem; + margin: 1rem 0; + border-radius: 0.3rem; + border: 1px solid ${theme.color.stroke2}; + color: ${theme.color.black}; + &:focus { + outline: none; + } + &::placeholder { + color: ${theme.color.lightGray}; + } +`; export const SubmitBtn = styled.button` margin-top: 3rem; padding: 0.65rem 3rem; @@ -272,13 +318,18 @@ export const SubmitBtn = styled.button` color: ${theme.color.white}; `; -export const SelectTerm = styled.div` +export const SelectTerm = styled.div<{ isSelected: boolean }>` margin-right: 0.3rem; padding: 0.55rem 1rem; border-radius: 0.3rem; border: 1px solid ${theme.color.stroke2}; color: ${theme.color.gray}; font-size: 0.9rem; + white-space: nowrap; + + background-color: ${({ isSelected }) => (isSelected ? `${theme.color.stroke2}` : 'white')}; + color: ${({ isSelected }) => isSelected && `${theme.color.text}`}; + cursor: pointer; `; @@ -291,14 +342,39 @@ export const TermWrapper = styled.div` /* background-color: red; */ `; -export const Week = styled(SelectTerm)` +export const Week = styled(SelectTerm)<{ isSelected: boolean }>` width: fit-content; padding: 0.5rem; + + background-color: ${({ isSelected }) => (isSelected ? `${theme.color.stroke2}` : 'white')}; + color: ${({ isSelected }) => isSelected && `${theme.color.text}`}; + &:last-child { margin-right: 0; } `; +export const Month = styled.div<{ isSelected: boolean }>` + width: 2rem; + padding: 0.5rem; + margin: 0.1rem; + padding: 0.55rem 1rem; + border-radius: 0.3rem; + border: 1px solid ${theme.color.stroke2}; + color: ${theme.color.gray}; + font-size: 0.9rem; + white-space: nowrap; + + display: flex; + justify-content: center; + align-items: center; + + background-color: ${({ isSelected }) => (isSelected ? `${theme.color.stroke2}` : 'white')}; + color: ${({ isSelected }) => isSelected && `${theme.color.text}`}; + + cursor: pointer; +`; + export const Date = styled.div``; export const DateContainer = styled.div` @@ -312,6 +388,7 @@ export const StyledDatePicker = styled.div` align-items: center; border: none; font-size: 0.9rem; + p { color: ${theme.color.gray}; white-space: nowrap; @@ -418,6 +495,13 @@ export const JoinButton = styled.div` cursor: pointer; `; +export const EditDelButtonWrapper = styled.div` + display: flex; + margin-right: 1.5rem; + /* position: absolute; + right: 10rem; */ +`; + export const EditDelButton = styled.img` width: 1rem; cursor: pointer; @@ -446,9 +530,11 @@ export const ChallengeDetailContainer = styled.div` `; // 추후 img로 변환 -export const ChallengeThumbnail = styled.div` +export const ChallengeThumbnail = styled.img` width: 35%; height: 40vh; + + object-fit: cover; border-radius: 1.25rem 0 0 1.25rem; background-color: ${theme.color.stroke2}; `; @@ -472,6 +558,13 @@ export const DetailSquareWrapper = styled.div` } `; +export const DetailDate = styled.p` + margin-top: 0.5rem; + line-height: 1.4rem; + /* font-weight: ${theme.font.weight.bold}; */ + color: ${theme.color.gray}; +`; + export const DetailContent = styled.div` margin-top: 0.5rem; line-height: 1.4rem; @@ -506,6 +599,7 @@ export const DetailRealTimeCountSquare = styled(DetailTermSquare)` export const ChallengeBlockPriview = styled.div` width: fit-content; + min-width: 20rem; max-width: 30rem; margin-top: 0.5rem; @@ -553,7 +647,7 @@ export const ChallengeCreatorContainer = styled.div` } `; -export const ProfileImage = styled.div` +export const ProfileImage = styled.img` width: 1.5rem; height: 1.5rem; margin-right: 0.5rem; @@ -584,7 +678,7 @@ export const RealTimeComponent = styled.div` align-items: center; `; -export const RealTimeUserImg = styled.div` +export const RealTimeUserImg = styled.img` width: 5rem; height: 5rem; border-radius: 50%; @@ -601,3 +695,8 @@ export const RealTimeUserName = styled.p` font-size: 1rem; color: ${theme.color.text}; `; + +export const errorMessage = styled.p` + font-size: 1rem; + color: ${theme.color.gray}; +`; diff --git a/src/types/ChallengeType.ts b/src/types/ChallengeType.ts new file mode 100644 index 0000000..ec9f48b --- /dev/null +++ b/src/types/ChallengeType.ts @@ -0,0 +1,108 @@ +// * 챌린지 타입 +export interface Challenge { + challengeId?: string; + title?: string; + contents?: string; + category?: string; + cycle?: string; + cycleDetails?: string[] | string; + startDate?: string; + endDate?: string; + representImage?: File | string; + authorName?: string; + authorProfileImage?: string; + blockName?: string; + participantCount?: 0; + isParticipant?: false; + isAuthor?: true; + completedMembers?: completedMember[]; +} + +// * 실시간 완료 멤버 타입 +export interface completedMember { + memberId?: string; + picture?: string; + nickname?: string; +} + +// * 페이지 정보 타입 +export interface PageInfoResDto { + currentPage: number; + totalPages: number; + totalItems: number; +} + +// * 최종 응답 타입 +export interface ChallengeResponse { + challengeInfoResDto: Challenge[]; + pageInfoResDto: PageInfoResDto; +} + +// * 챌린지 카테고리 +export const ChallengeCategory = { + HEALTH_AND_FITNESS: '건강 및 운동', + MENTAL_WELLNESS: '정신 및 마음관리', + PRODUCTIVITY_AND_TIME_MANAGEMENT: '생산성 및 시간 관리', + FINANCE_AND_ASSET_MANAGEMENT: '재정 및 자산 관리', + SELF_DEVELOPMENT: '자기 개발', + LIFE_ORGANIZATION_AND_MANAGEMENT: '생활 정리 및 관리', + SOCIAL_CONNECTIONS: '사회적 연결', + CREATIVITY_AND_ARTS: '창의력 및 예술 활동', + OTHERS: '기타', +}; + +// * 챌린지 주기 +export const ChallengeCycle = { + DAILY: '매일', + WEEKLY: '매주', + MONTHLY: '매달', +}; + +// * 챌린지 주기 상세 정보 +export const ChallengeCycleDetail = { + DAILY: '매일', +}; + +export const ChallengeCycleDetail_Weekly = { + MON: '월', + TUE: '화', + WED: '수', + THU: '목', + FRI: '금', + SAT: '토', + SUN: '일', +}; + +export const ChallengeCycleDetail_Monthly = { + FIRST: '1', + SECOND: '2', + THIRD: '3', + FOURTH: '4', + FIFTH: '5', + SIXTH: '6', + SEVENTH: '7', + EIGHTH: '8', + NINTH: '9', + TENTH: '10', + ELEVENTH: '11', + TWELFTH: '12', + THIRTEENTH: '13', + FOURTEENTH: '14', + FIFTEENTH: '15', + SIXTEENTH: '16', + SEVENTEENTH: '17', + EIGHTEENTH: '18', + NINETEENTH: '19', + TWENTIETH: '20', + TWENTY_FIRST: '21', + TWENTY_SECOND: '22', + TWENTY_THIRD: '23', + TWENTY_FOURTH: '24', + TWENTY_FIFTH: '25', + TWENTY_SIXTH: '26', + TWENTY_SEVENTH: '27', + TWENTY_EIGHTH: '28', + TWENTY_NINTH: '29', + THIRTIETH: '30', + THIRTY_FIRST: '31', +};