diff --git a/.github/workflow/ci.yml b/.github/workflow/ci.yml new file mode 100644 index 0000000..5cc3024 --- /dev/null +++ b/.github/workflow/ci.yml @@ -0,0 +1,26 @@ +name: ESLint test + +on: + pull_request: + branches: + - main + - develop + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Pnpm package manager + run: | + npm install -g pnpm + + - name: Install Dependencies + run: pnpm install + + - name: Lint Code + run: pnpm lint diff --git a/src/apis/challenge-list/getChallengeList.api.ts b/src/apis/challenge-list/getChallengeList.api.ts new file mode 100644 index 0000000..84ec38f --- /dev/null +++ b/src/apis/challenge-list/getChallengeList.api.ts @@ -0,0 +1,27 @@ +import { ChallengeListResponse } from './getChallengeList.response'; +import { axiosClient } from '@/apis/AxiosClient'; +import { useQuery } from '@tanstack/react-query'; + +const getChallengeListPath = () => '/api/challengeGroups/shorts'; + +const challengeListQueryKey = [getChallengeListPath()]; + +const getChallengeList = async ( + page: number, + size: number +): Promise => { + const response = await axiosClient.get(getChallengeListPath(), { + params: { + page, + size, + }, + }); + return response.data; +}; + +export const useGetChallengeList = (page: number, size: number) => { + return useQuery({ + queryKey: [challengeListQueryKey, page, size], + queryFn: () => getChallengeList(page, size), + }); +}; diff --git a/src/apis/challenge-list/getChallengeList.response.ts b/src/apis/challenge-list/getChallengeList.response.ts new file mode 100644 index 0000000..7e04e68 --- /dev/null +++ b/src/apis/challenge-list/getChallengeList.response.ts @@ -0,0 +1,17 @@ +import ApiResponse from '@/apis/ApiResponse'; + +export type ChallengeListData = { + totalPage: number; + hasNext: boolean; + data: { + id: number; + title: string; + content: string; + participantCount: number; + startDate: string; + endDate: string; + category: 'HEALTH' | 'ECHO' | 'SHARE' | 'VOLUNTEER' | 'ETC'; + }[]; +}; + +export type ChallengeListResponse = ApiResponse; diff --git a/src/apis/review/review.api.ts b/src/apis/review/review.api.ts index b5c9f74..9249b07 100644 --- a/src/apis/review/review.api.ts +++ b/src/apis/review/review.api.ts @@ -3,7 +3,6 @@ import { AxiosError } from 'axios'; import { axiosClient } from '../AxiosClient'; import type { GetReviewResponse, - PostReviewData, ChallengeAvgScoreData, } from './review.response'; @@ -65,31 +64,30 @@ type PostReviewParams = { challengeId: number; content: string; rating: number; + difficulty: number | undefined; + achievement: number | undefined; }; export async function postReview({ challengeId, content, rating, -}: PostReviewParams): Promise { - const body = { content, rating }; - // console.log('json : ', JSON.stringify(body)); + difficulty, + achievement, +}: PostReviewParams): Promise { + const requestBody = { content, rating, difficulty, achievement }; try { const response = await axiosClient.post( `/api/challenges/${challengeId}/reviews`, - body + requestBody ); console.log('postReview response: ', response.data); - - return response.data.data; } catch (error) { - if (error instanceof AxiosError) { - throw new Error( - `postReview error: ${error.response?.data.message || error.message}` - ); - } else { - throw new Error('postReview error: unexpected'); + if (error instanceof AxiosError && error.response) { + throw error.response.data; } + // AxiosError가 아닌 경우 일반적인 예외 처리 + throw new Error('postReview error: unexpected'); } } diff --git a/src/apis/review/review.response.ts b/src/apis/review/review.response.ts index d2c382a..fb896e3 100644 --- a/src/apis/review/review.response.ts +++ b/src/apis/review/review.response.ts @@ -20,6 +20,9 @@ export type ReviewData = { user: User; content: string; rating: number; + difficulty: number; + achievement: number; + createdAt: string; }; export const DummyReviewList: ReviewData[] = [ @@ -40,6 +43,9 @@ export const DummyReviewList: ReviewData[] = [ content: '매일 매일 꾸준히 했더니 습관이 형성되었어요. 습관도 만들고 포인트도 얻고 좋아요 굿', rating: 4, + difficulty: 1, + achievement: 1, + createdAt: '2024-09-26T21:16:18', }, ]; @@ -53,5 +59,3 @@ export type ChallengeAvgScoreData = { }; // - -export type PostReviewData = number; diff --git a/src/components/common/chip/index.tsx b/src/components/common/chip/index.tsx new file mode 100644 index 0000000..e341dac --- /dev/null +++ b/src/components/common/chip/index.tsx @@ -0,0 +1,14 @@ +import styled from '@emotion/styled'; + +export const Chip = styled.div<{ margin?: string; color?: string }>` + padding: 4px 12px; + border-radius: 50px; + border: ${({ color }) => + color ? `${color} 0.5px solid` : `var(--color-green-01) 0.5px solid`}; + background-color: var(--color-white); + color: ${({ color }) => (color ? color : `var(--color-green-01)`)}; + font-size: var(--font-size-xs); + font-weight: 600; + text-align: center; + margin: ${({ margin }) => (margin ? `${margin}` : null)}; +`; diff --git a/src/components/common/profile-image/index.tsx b/src/components/common/profile-image/index.tsx new file mode 100644 index 0000000..68dd33f --- /dev/null +++ b/src/components/common/profile-image/index.tsx @@ -0,0 +1,14 @@ +import UserImage from '@/assets/UserImage.svg'; +import styled from '@emotion/styled'; + +export const ProfileImage = styled.div<{ size?: number; src?: string }>` + width: ${({ size }) => (size ? `${size}rem` : '3rem')}; + height: ${({ size }) => (size ? `${size}rem` : '3rem')}; + aspect-ratio: 1 / 1; + border-radius: 50%; + + background-image: url(${({ src }) => src || UserImage}); + background-size: cover; + background-position: center; + background-repeat: no-repeat; +`; diff --git a/src/components/common/star-rating/index.tsx b/src/components/common/star-rating/index.tsx index a1bd20a..4717606 100644 --- a/src/components/common/star-rating/index.tsx +++ b/src/components/common/star-rating/index.tsx @@ -5,35 +5,45 @@ import styled from '@emotion/styled'; interface StarRatingProps { rating: number; size?: number; + onClick?: (rating: number) => void; } -export const StarRating = ({ rating, size = 24 }: StarRatingProps) => { - // rating(별점)을 백분율로 변환 - const [ratingToPercent, setRatingToPercent] = useState(`0%`); +export const StarRating = ({ rating, size = 24, onClick }: StarRatingProps) => { + const [ratingToPercent, setRatingToPercent] = useState(0); useEffect(() => { if (rating !== undefined) { - setRatingToPercent(`${(rating / 5) * 100}%`); + setRatingToPercent((rating / 5) * 100); } }, [rating]); + const handleClick = (rating: number) => { + if (onClick) { + onClick(rating + 1); // 클릭한 별점 값 전달 (1부터 시작) + } + }; + return ( - + {[...Array(5)].map((_, index) => ( - + handleClick(index)}> + ★ + ))} - - + + {[...Array(5)].map((_, index) => ( - + handleClick(index)}> + ★ + ))} - + ); }; -const Wrapper = styled.div<{ size: number }>` +const Wrapper = styled.div<{ size: number; cursor?: string }>` position: relative; unicode-bidi: bidi-override; width: max-content; @@ -41,15 +51,23 @@ const Wrapper = styled.div<{ size: number }>` -webkit-text-stroke-width: 0.8px; -webkit-text-stroke-color: var(--color-green-01); font-size: ${({ size }) => `${size}px`}; + cursor: ${({ cursor }) => cursor && 'pointer'}; `; -const StarFill = styled.div` +const FilledStars = styled.div<{ ratingToPercent: number }>` position: absolute; display: flex; top: 0; left: 0; + width: ${({ ratingToPercent }) => `${ratingToPercent}%`}; overflow: hidden; -webkit-text-fill-color: var(--color-green-01); `; -const StarBase = styled.div``; +const BaseStars = styled.div` + display: flex; +`; + +const Star = styled.button` + outline: none; +`; diff --git a/src/components/common/tab/index.tsx b/src/components/common/tab/index.tsx deleted file mode 100644 index 4dc8a37..0000000 --- a/src/components/common/tab/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { cloneElement, ReactElement, useEffect, useRef, useState } from 'react'; - -import * as S from './styles'; - -type TabProps = { - label: string; - value: number; - active?: boolean; - onClick?: () => void; -}; - -type TabsProps = { - selectedTab: number; - onChange: (value: number) => void; - children: ReactElement[]; - position?: string; -}; - -type TabPanelProps = { - children?: ReactElement; - value: number; - selectedIndex: number; -}; - -export const Tab = ({ label, active, onClick }: TabProps) => { - return ( - - {label} - - ); -}; - -export const Tabs = ({ - selectedTab, - onChange, - children, - position, -}: TabsProps) => { - const containerRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - - useEffect(() => { - if (containerRef.current) { - setContainerWidth(containerRef.current.getBoundingClientRect().width); - } - }, [containerRef]); - - const sliderWidth = containerWidth / children.length; - - const tabs = children.map((child) => { - const handleClick = () => { - onChange(child.props.value); - }; - - return cloneElement(child, { - key: child.props.value, - active: child.props.value === selectedTab, - onClick: handleClick, - }); - }); - - return ( - - {tabs} - - - ); -}; - -export const TabPanel = ({ children, value, selectedIndex }: TabPanelProps) => { - const hidden: boolean = value !== selectedIndex; - - return ( - - ); -}; diff --git a/src/components/common/tab/styles.ts b/src/components/common/tab/styles.ts deleted file mode 100644 index d542d83..0000000 --- a/src/components/common/tab/styles.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { css, keyframes } from '@emotion/react'; -import styled from '@emotion/styled'; - -// 챌린지 상세 페이지 css - -export const TabsContainer = styled.div` - display: flex; - position: relative; - top: 50px; - align-self: center; - width: 90%; - margin: 0 auto; - height: 55px; - border-radius: 20px; - background-color: var(--color-green-06); -`; - -export const TabPanelContainer = styled.div` - height: 100%; - width: 100%; - position: relative; - top: 50px; - text-align: center; -`; - -export const Image = styled.img` - position: relative; - margin: auto; - display: block; - height: 40%; - opacity: 20%; - object-fit: cover; - margin-bottom: 28px; - filter: grayscale(100%); -`; - -export const ImageMask = styled.div` - background-color: var(--color-green-06); - position: relative; - top: 50px; -`; - -export const Wrapper = styled.div``; - -type StylizedTabProps = { - active?: boolean; - onClick?: () => void; - inactiveStyle?: React.CSSProperties; -}; - -export const StylizedTab = styled.div` - z-index: 1; - color: var(--color-grey-02); - width: 50%; /* 각 Tab의 너비를 50%로 설정하여 두 개의 Tab이 꽉 차도록 설정 */ - font-size: var(--font-size-md); - background-color: transparent; - border: none; - height: 50px; - text-align: center; - line-height: 50px; - cursor: pointer; - - ${(p) => - p.active && - css` - color: var(--color-white); - font-weight: bold; - border-radius: 20px; - animation: ${inset} 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; - `} -`; - -type StyledTabPanelProps = { - active: boolean; -}; - -export const StyledTabPanel = styled.div` - display: ${(p) => (p.active ? 'flex' : 'none')}; - font-size: 2rem; - flex-direction: column; - width: 100%; - height: 100%; - justify-content: center; -`; - -type TabHeaderContainerProps = { - position?: string; -}; - -export const TabHeaderContainer = styled.div` - position: ${(props) => props.position || 'absolute'}; - width: 100%; -`; - -export const TabsHolder = styled.div` - display: flex; - justify-content: space-between; /* Tab들이 좌우로 정렬되도록 설정 */ - width: 100%; -`; - -type TabSliderProps = { - width: number; - index: number; -}; - -export const TabSlider = styled.div` - position: absolute; - top: 3px; - height: 50px; - background-color: var(--color-green-01); - border-radius: 20px; - transition: 0.2s; - transform: ${({ width, index }) => `translateX(${width * index}px)`}; - width: ${({ width }) => `${width}px`}; -`; - -const inset = keyframes` - 0% { - -webkit-box-shadow: 0 0 0 0 rgba(92, 198, 186, 0); - box-shadow: 0 0 0 0 rgba(92, 198, 186, 0); - } - 100% { - -webkit-box-shadow: 3px 3px 3px rgba(92, 198, 186, 0.5); - box-shadow: 3px 3px 3px rgba(92, 198, 186, 0.5); - } - `; diff --git a/src/components/common/tabs/index.tsx b/src/components/common/tabs/index.tsx new file mode 100644 index 0000000..ac424d5 --- /dev/null +++ b/src/components/common/tabs/index.tsx @@ -0,0 +1,131 @@ +import { + cloneElement, + forwardRef, + ReactElement, + useEffect, + useRef, + useState, +} from 'react'; + +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +type TabsProps = { + selectedTab: number; + onChange: (value: number) => void; + children: ReactElement[]; +}; + +export const Tabs = ({ selectedTab, onChange, children }: TabsProps) => { + const containerRef = useRef(null); + const tabRefs = useRef<(HTMLDivElement | null)[]>([]); // Tab refs 배열 + const [tabWidths, setTabWidths] = useState([]); // 각 Tab 너비 저장 + + // 각 Tab의 너비를 계산하여 상태로 저장 + useEffect(() => { + if (tabRefs.current.length > 0) { + const widths = tabRefs.current.map( + (ref) => ref?.getBoundingClientRect().width || 0 + ); + setTabWidths(widths); + } + }, [children]); + + const sliderWidth = tabWidths[selectedTab] - 4 || 0; // 여백 4px 빼기 + + const tabs = children.map((child, index) => { + const handleClick = () => { + onChange(child.props.value); + }; + + return cloneElement(child, { + key: child.props.value, + active: child.props.value === selectedTab, + onClick: handleClick, + ref: (el: HTMLDivElement) => (tabRefs.current[index] = el), // Tab 요소에 ref 연결 + }); + }); + + return ( + + {tabs} + + + ); +}; + +type TabProps = { + label: string; + value: number; + active?: boolean; + onClick?: () => void; +}; + +export const Tab = forwardRef( + ({ label, active, onClick }, ref) => { + return ( + + {label} + + ); + } +); + +// displayName 설정으로 경고 해결 +Tab.displayName = 'Tab'; + +export const StyledTabs = styled.div<{ + position?: string; +}>` + display: flex; + position: relative; + height: 46px; + margin: 0 16px; + padding: 4px 0; + border-radius: 10px; + background-color: var(--color-green-06); +`; + +// 선택된 탭 +export const TabSlider = styled.div<{ + width: number; + index: number; +}>` + position: absolute; + top: 4px; + left: 4px; + width: ${({ width }) => `${width}px`}; + height: 38px; + background-color: var(--color-white); + border-radius: 10px; + + /* 슬라이딩 애니메이션 */ + transition: 0.2s; + transform: ${({ width, index }) => `translateX(${width * index}px)`}; +`; + +export const StyledTab = styled.div<{ + active?: boolean; + onClick?: () => void; + inactiveStyle?: React.CSSProperties; +}>` + z-index: 1; + width: 50%; + height: 100%; + white-space: nowrap; // 글자 줄바꿈 없음 + display: flex; + justify-content: center; + align-items: center; + background-color: transparent; + border: none; + font-size: var(--font-size-md); + color: var(--color-grey-02); + cursor: pointer; + + ${(p) => + p.active && + css` + color: var(--color-green-01); + font-weight: 600; + `} +`; diff --git a/src/components/common/tabs/tab-panels/index.tsx b/src/components/common/tabs/tab-panels/index.tsx new file mode 100644 index 0000000..220e035 --- /dev/null +++ b/src/components/common/tabs/tab-panels/index.tsx @@ -0,0 +1,45 @@ +import { ReactElement } from 'react'; + +import styled from '@emotion/styled'; + +type TapPanelsProps = { + children: ReactElement[]; +}; + +export const TabPanels = ({ children }: TapPanelsProps) => { + return {children}; +}; + +type TabPanelProps = { + children?: ReactElement; + value: number; + selectedIndex: number; +}; + +export const TabPanel = ({ children, value, selectedIndex }: TabPanelProps) => { + const hidden: boolean = value !== selectedIndex; + + return ( + + ); +}; + +export const StyledTabPanels = styled.div` + height: 100%; + width: 100%; + position: relative; + text-align: center; +`; + +export const StyledTabPanel = styled.div<{ + active: boolean; +}>` + display: ${(p) => (p.active ? 'flex' : 'none')}; + font-size: 2rem; + flex-direction: column; + width: 100%; + height: 100%; + justify-content: center; +`; diff --git a/src/pages/challenge-detail/components/challenge-item/index.tsx b/src/pages/challenge-detail/components/challenge-item/index.tsx new file mode 100644 index 0000000..0ba4453 --- /dev/null +++ b/src/pages/challenge-detail/components/challenge-item/index.tsx @@ -0,0 +1,79 @@ +import { useNavigate } from 'react-router-dom'; + +import * as S from './styles'; +import { joinChallenge } from '@/apis/challenge-detail/challenge.detail.api'; +import { type Challenge } from '@/apis/challenge-detail/challenge.detail.response'; +import { Chip } from '@/components/common/chip'; +import { getDynamicPath } from '@/routes/protected-route'; + +type Props = { + challenge: Challenge; + maxDifficulty: number; +}; + +const ChallengeItem = ({ challenge, maxDifficulty }: Props) => { + const difficultyRate = (challenge.difficulty / maxDifficulty) * 100; + const navigate = useNavigate(); + + const handleJoinChallenge = () => { + joinChallenge(challenge.id) + .then((res) => { + alert(res.message); + }) + .catch((error) => { + // API에서 받은 오류 객체일 경우 + if (error.result === 'FAIL') { + if (error.errorCode === 'UNAUTHORIZED') { + alert('로그인 후 시도해주세요.'); + navigate(getDynamicPath.login()); + } else { + alert(error.message || '다시 시도해주세요.'); + } + } + // 예상치 못한 오류 처리 + else { + alert('다시 시도해주세요.'); + } + }); + }; + + return ( + + + 난이도 + + + + + + + {challenge.difficulty} +  / {maxDifficulty} + + + + + 참여 횟수 및 +
기간 +
+ + {challenge.count}회 / {challenge.period}일 + + + 참여 경험치 + + + {challenge.onceExp} 포인트 + + + 완료 경험치 + + + {challenge.successExp} 포인트 + +
+ + 참여하기 +
+ ); +}; + +export default ChallengeItem; diff --git a/src/pages/challenge-detail/components/challenge/styles.ts b/src/pages/challenge-detail/components/challenge-item/styles.ts similarity index 50% rename from src/pages/challenge-detail/components/challenge/styles.ts rename to src/pages/challenge-detail/components/challenge-item/styles.ts index 4bc366a..7ffa057 100644 --- a/src/pages/challenge-detail/components/challenge/styles.ts +++ b/src/pages/challenge-detail/components/challenge-item/styles.ts @@ -1,77 +1,83 @@ import styled from '@emotion/styled'; -export const Outer = styled.div` +export const Wrapper = styled.div` border-radius: 20px; border: var(--color-grey-02) 0.5px solid; padding: 16px; flex-shrink: 0; -`; - -export const Wrapper = styled.div` display: flex; flex-direction: column; - gap: 4px; - padding-bottom: 16px; `; -export const RowWrapper = styled.div` - display: flex; - flex-direction: row; - align-items: center; +export const ContentGrid = styled.div` + display: grid; + grid-template-columns: max-content 1fr; gap: 8px; + align-items: center; `; -export const BoldText = styled.div` +export const BoldText = styled.span` font-size: var(--font-size-sm); - font-weight: bold; + font-weight: 600; flex-shrink: 0; `; -export const Text = styled.div` +export const Text = styled.span` font-size: var(--font-size-sm); `; -export const SubText = styled.div` +export const SubText = styled.span` font-size: var(--font-size-sm); color: var(--color-grey-02); flex-shrink: 0; `; -export const Bar = styled.div<{ width: number }>` - border-radius: 10px; +export const DifficultyBox = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +export const BarBox = styled.div` + position: relative; + display: inline-block; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100px; height: 10px; - background: linear-gradient(90deg, #457a82, #5cc6ba); - width: ${({ width }) => `${width}px`}; - flex-shrink: 0; `; export const MaxBar = styled.div` + position: absolute; + top: 0; + left: 0; border-radius: 10px; height: 10px; - background: var(--color-grey-02); + background: var(--color-green-06); width: 100px; - flex-shrink: 0; `; -export const ExpContent = styled.div` - font-size: var(--font-size-xs); - background-size: contain; - text-align: center; - color: var(--color-green-01); - padding: 3px; - border-radius: 20px; - border: var(--color-green-01) 1px solid; - flex-shrink: 0; - padding: 2px 6px; +export const Bar = styled(MaxBar)<{ width: number }>` + background: linear-gradient(90deg, #457a82, #5cc6ba); + width: ${({ width }) => `${width}px`}; + z-index: 1; `; -export const Btn = styled.button` - border-radius: 20px; +export const TimesPeriodContent = styled(Text)` + margin: 0 0 0 auto; +`; + +export const CTA = styled.button` + width: calc(100% - 16px); // 부모 요소의 좌우 padding 빼고 + padding: 10px 8px; + margin: auto; + border-radius: 10px; background-color: var(--color-green-01); color: var(--color-white); font-weight: bold; font-size: var(--font-size-md); - width: 100%; - height: 45px; - border: none; + + margin-top: 16px; `; diff --git a/src/pages/challenge-detail/components/challenge/index.tsx b/src/pages/challenge-detail/components/challenge/index.tsx deleted file mode 100644 index 7ab5527..0000000 --- a/src/pages/challenge-detail/components/challenge/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useNavigate } from 'react-router-dom'; - -import * as S from './styles'; -import { joinChallenge } from '@/apis/challenge-detail/challenge.detail.api'; -import { type Challenge } from '@/apis/challenge-detail/challenge.detail.response'; -import { RouterPath } from '@/routes/path'; - -type Props = { - challenge: Challenge; - maxDifficulty: number; -}; - -const Challenge = ({ challenge, maxDifficulty }: Props) => { - const difficultyRate = (challenge.difficulty / maxDifficulty) * 100; - const navigate = useNavigate(); - - const clickJoinChallenge = () => { - joinChallenge(challenge.id) - .then((res) => { - alert(res.message); - }) - .catch((error) => { - // API에서 받은 오류 객체일 경우 - if (error.result === 'FAIL') { - if (error.errorCode === 'UNAUTHORIZED') { - alert('로그인 후 시도해주세요.'); - navigate(RouterPath.auth); - } else { - alert(error.message || '다시 시도해주세요.'); - } - } - // 예상치 못한 오류 처리 - else { - alert('다시 시도해주세요.'); - } - }); - }; - - return ( - - - - 난이도 - 최대 난이도 - - - - - {challenge.difficulty} - - - - {maxDifficulty} - - - - - - 참여 횟수 및 기간 - - {challenge.count}회/{challenge.period}일 - - - - 참여 경험치 - {challenge.onceExp} 포인트 - - - 완료 경험치 - {challenge.successExp} 포인트 - - - 참여하기 - - ); -}; - -export default Challenge; diff --git a/src/pages/challenge-detail/components/ranking-item/index.tsx b/src/pages/challenge-detail/components/ranking-item/index.tsx index 92bf024..82f1458 100644 --- a/src/pages/challenge-detail/components/ranking-item/index.tsx +++ b/src/pages/challenge-detail/components/ranking-item/index.tsx @@ -1,6 +1,8 @@ // import DefaultImage from '../../../../assets/UserImage.svg'; import * as S from './styles'; import type { ChallengeRankingData } from '@/apis/challenge-detail/challenge.ranking.response'; +import { Chip } from '@/components/common/chip'; +import { ProfileImage } from '@/components/common/profile-image'; type RankingItemProps = { item: ChallengeRankingData; @@ -11,15 +13,15 @@ export const RankingItem = ({ item }: RankingItemProps) => { {item.ranking}위 - - - + {item.user.nickname} {item.user.tierInfo.tier} - + {item.acquiredPoint} 포인트 + + + {item.acquiredPoint} 포인트 + ); }; diff --git a/src/pages/challenge-detail/components/ranking-item/styles.ts b/src/pages/challenge-detail/components/ranking-item/styles.ts index a192fe2..ab866f4 100644 --- a/src/pages/challenge-detail/components/ranking-item/styles.ts +++ b/src/pages/challenge-detail/components/ranking-item/styles.ts @@ -46,22 +46,8 @@ export const Tier = styled.div` text-align: left; `; -export const Point = styled.div` - font-size: var(--font-size-xs); - color: var(--color-green-05); - margin-left: 12px; -`; - -export const ImageBox = styled.div` - height: 3rem; - width: 3rem; - border-radius: 70%; - overflow: hidden; - aspect-ratio: 1 / 1; -`; - -export const Image = styled.img` - height: 100%; - width: 100%; - object-fit: cover; -`; +// export const Point = styled.div` +// font-size: var(--font-size-xs); +// color: var(--color-green-05); +// margin-left: 12px; +// `; diff --git a/src/pages/challenge-detail/description-section/index.tsx b/src/pages/challenge-detail/description-section/index.tsx index 17b2de4..e191ab9 100644 --- a/src/pages/challenge-detail/description-section/index.tsx +++ b/src/pages/challenge-detail/description-section/index.tsx @@ -1,9 +1,10 @@ import { ReactNode } from 'react'; -import Challenge from '../components/challenge'; +import ChallengeItem from '../components/challenge-item'; import * as S from './styles'; import { type ChallengeDetailData } from '@/apis/challenge-detail/challenge.detail.response'; import * as Base from '@/styles/baseStyles'; +import { formatDate } from '@/utils/formatters'; type DescriptionSectionProps = { data: ChallengeDetailData; @@ -14,6 +15,9 @@ export const DescriptionSection = ({ }: DescriptionSectionProps): ReactNode => { const challenges = data.challenges; + const formattedStartDate = formatDate(data.startDate); + const formattedEndDate = formatDate(data.endDate); + return ( @@ -23,7 +27,7 @@ export const DescriptionSection = ({ 챌린지 신청 가능 기간 - {data.startDate} ~ {data.endDate} + {formattedStartDate} ~ {formattedEndDate}
@@ -32,11 +36,11 @@ export const DescriptionSection = ({ {data.guide} - + {challenges.map((item) => ( - { const tabsList = [ { label: '설명', - component: data ? : null, + panel: data ? : null, }, { label: '랭킹', - component: data ? : null, + panel: data ? : null, }, { label: '리뷰', - component: data ? : null, + panel: data ? : null, }, ]; @@ -55,7 +57,7 @@ const ChallengeDetailPage = () => { }; fetchChallengeDetail(); - }, []); + }, [challengeGroupId]); // 챌린지 리뷰 페이지에 필요한 챌린지 제목 세션 스토리지에 저장 useEffect(() => { @@ -65,35 +67,94 @@ const ChallengeDetailPage = () => { }, [data?.title]); return ( - - - - {data?.imageUrls?.length ? ( - data.imageUrls.map((img, index) => ) - ) : ( - - )} - - - {data?.category} - {data?.title} - - + <> + + + + {data?.imageUrls?.length ? ( + data.imageUrls.map((img, index) => ) + ) : ( + + + + )} + + + + {formatCategory(data?.category)} + {data?.title} + + {tabsList.map((t, index) => ( ))} - - - {tabsList.map((t, index) => ( - - {t.component ?? undefined} - - ))} - - + + {tabsList.map((t, index) => ( + + {t.panel ?? undefined} + + ))} + + + ); }; export default ChallengeDetailPage; + +export const Wrapper = styled.div` + width: 100%; + margin-bottom: 3.44rem; // 하단 내브 바 높이 +`; + +export const ImageList = styled.div` + margin: 0 0 16px; + height: 100vw; + max-height: 480px; // 최대 너비가 480px라서 고정값으로 설정한 것임 + display: flex; + overflow-x: scroll; +`; + +export const Image = styled.img` + position: relative; + margin: auto; + align-self: center; + display: block; + object-fit: cover; + width: 100%; +`; + +export const DefaultImageMask = styled.div` + background-color: var(--color-green-06); + position: relative; + width: 100vw; + display: flex; +`; + +export const StyledDefaultImage = styled.img` + position: relative; + margin: auto; + align-self: center; + display: block; + object-fit: cover; + opacity: 50%; +`; + +export const ChallengeTitleWrapper = styled.div` + margin: 16px; + display: flex; + flex-direction: column; + text-align: left; +`; + +export const Category = styled.div` + font-size: var(--font-size-xs); + color: var(--color-green-01); +`; + +export const Title = styled.div` + font-size: var(--font-size-xl); + font-weight: bold; +`; diff --git a/src/pages/challenge-detail/ranking-section/index.tsx b/src/pages/challenge-detail/ranking-section/index.tsx index 35505d8..3451d16 100644 --- a/src/pages/challenge-detail/ranking-section/index.tsx +++ b/src/pages/challenge-detail/ranking-section/index.tsx @@ -59,7 +59,7 @@ export const RankingSection = ({ id }: RankingSectionProps) => {
{index < rankingList.length - 1 && ( - + )} {/* 마지막 요소 뒤에는 Line을 넣지 않음 */}
diff --git a/src/pages/challenge-detail/ranking-section/styles.ts b/src/pages/challenge-detail/ranking-section/styles.ts index b80b4a5..18e486c 100644 --- a/src/pages/challenge-detail/ranking-section/styles.ts +++ b/src/pages/challenge-detail/ranking-section/styles.ts @@ -8,7 +8,6 @@ export const Text = styled.span<{ fontWeight?: string; color?: string }>` export const RankingWrapper = styled.div` padding: 16px 16px; - margin: 0 0 3.44rem; // 하단 내브 바 높이 display: flex; flex-direction: column; `; diff --git a/src/pages/challenge-detail/review-section/index.tsx b/src/pages/challenge-detail/review-section/index.tsx index e6497fd..4edddf4 100644 --- a/src/pages/challenge-detail/review-section/index.tsx +++ b/src/pages/challenge-detail/review-section/index.tsx @@ -8,6 +8,7 @@ import { type ReviewData } from '@/apis/review/review.response'; import { StarRating } from '@/components/common/star-rating'; import ReviewItem from '@/pages/review/components/review-item'; import * as Base from '@/styles/baseStyles'; +import { formatToFixed, formatWithComma } from '@/utils/formatters'; interface Props { id: number; @@ -17,21 +18,23 @@ export const ReviewSection = ({ id }: Props) => { const DATA_SIZE = 5; // 가져올 리뷰 개수 const [reviewList, setReviewList] = useState([]); const [avgRating, setAvgRating] = useState(); - const [totalRatings, setTotalRatings] = useState(0); // 별점(리뷰) 개수 + const [formattedAvgRating, setFormattedAvgRating] = useState('0.0'); // 소수점 한 자리까지 포맷팅된 별점 평균 + const [formattedTotalRatings, setFormattedTotalRatings] = useState(''); // 쉼표 포맷팅된 별점(리뷰) 개수 const navigate = useNavigate(); useEffect(() => { // 평균 별점 가져오기 getChallegeAvgScore({ challengeGroupId: id }) .then((res) => { - setAvgRating(Number(res.averageRating.toFixed(1))); // 소수점 아래 한 자리 + setAvgRating(res.averageRating); + setFormattedAvgRating(formatToFixed(res.averageRating)); - // 모든 별점 개수 합 구하기 + // 모든 별점 개수 합 구하여 저장 const total = Object.values(res.ratingCount).reduce( (acc, value) => acc + value, 0 ); - setTotalRatings(total); + setFormattedTotalRatings(formatWithComma(total)); }) .catch((error) => { console.error('Error fetching average score:', error); @@ -42,7 +45,6 @@ export const ReviewSection = ({ id }: Props) => { .then((res) => { if (Array.isArray(res.data) && res.data.length > 0) { setReviewList((prevReviewList) => [...prevReviewList, ...res.data]); - // console.log(`리뷰 리스트: `, reviewList); // test } else { console.log('리뷰 데이터 없음'); } @@ -58,24 +60,27 @@ export const ReviewSection = ({ id }: Props) => { // 리뷰 있을 때 <> - {avgRating} + {formattedAvgRating} {avgRating && } navigate(`/challenge/${id}/review`)} > - {totalRatings}개 모두 보기{' '} + {formattedTotalRatings}개 모두 보기{' '} - {reviewList.map((review, index) => ( -
- - {index < reviewList.length - 1 && ( - - )} - {/* 마지막 요소 뒤에는 Line을 넣지 않음 */} -
- ))} + + + {reviewList.map((review, index) => ( +
+ + {index < reviewList.length - 1 && ( + + )} + {/* 마지막 요소 뒤에는 Line을 넣지 않음 */} +
+ ))} +
) : ( // 리뷰 없을 때 diff --git a/src/pages/challenge-detail/review-section/styles.ts b/src/pages/challenge-detail/review-section/styles.ts index ea3c2b5..5ee8279 100644 --- a/src/pages/challenge-detail/review-section/styles.ts +++ b/src/pages/challenge-detail/review-section/styles.ts @@ -16,8 +16,6 @@ export const Text = styled.span<{ fontWeight?: string; color?: string }>` `; export const Wrapper = styled.div` - padding: 16px 16px; - margin: 0 0 3.44rem; // 하단 내브 바 높이 display: flex; flex-direction: column; height: auto; @@ -28,8 +26,8 @@ export const RatingContainer = styled.div` align-items: center; gap: 12px; flex: 1; - width: 100%; - margin: 0 0 16px 0; + /* width: 100%; */ + margin: 16px; `; export const AvgRating = styled.p` @@ -40,7 +38,8 @@ export const AvgRating = styled.p` export const AllReviewButton = styled.button` font-size: var(--font-size-sm); font-weight: 600; - display: flex; + color: var(--color-grey-01); + display: inline-flex; align-items: center; margin: 0 0 0 auto; cursor: pointer; @@ -49,3 +48,7 @@ export const AllReviewButton = styled.button` export const Line = styled.div` border-top: 1px solid var(--color-green-06); `; + +export const ReviewList = styled.div` + margin: 16px; +`; diff --git a/src/pages/challenge-detail/styles.ts b/src/pages/challenge-detail/styles.ts deleted file mode 100644 index 7365586..0000000 --- a/src/pages/challenge-detail/styles.ts +++ /dev/null @@ -1,53 +0,0 @@ -import styled from '@emotion/styled'; - -export const TabsContainer = styled.div` - display: flex; - position: relative; - align-self: center; - margin: 0 16px; - height: 55px; - border-radius: 20px; - background-color: var(--color-green-06); -`; - -export const TabPanelContainer = styled.div` - height: 100%; - width: 100%; - position: relative; - text-align: center; -`; - -export const ImageMask = styled.div` - background-color: var(--color-green-06); - position: relative; - margin: 0 0 16px; -`; - -export const Image = styled.img` - position: relative; - margin: auto; - display: block; - height: 40%; - opacity: 20%; - object-fit: cover; - filter: grayscale(100%); -`; - -export const Wrapper = styled.div``; - -export const ChallengeTitleWrapper = styled.div` - margin: 16px; - display: flex; - flex-direction: column; - text-align: left; -`; - -export const Category = styled.div` - font-size: var(--font-size-xs); - color: var(--color-green-01); -`; - -export const Title = styled.div` - font-size: var(--font-size-xl); - font-weight: bold; -`; diff --git a/src/pages/challenge-list/components/contents/index.tsx b/src/pages/challenge-list/components/contents/index.tsx new file mode 100644 index 0000000..2aa0b64 --- /dev/null +++ b/src/pages/challenge-list/components/contents/index.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; + +import { Box, Text } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +type Props = { + title: string; + content: string; + startDate: string; + endDate: string; + participantCount: number; +}; + +const Contents = ({ + title, + content, + startDate, + endDate, + participantCount, +}: Props) => { + const [isClicked, setIsClicked] = useState(false); + + const handleBoxClick = () => { + setIsClicked(!isClicked); + }; + + const date = `${startDate} ~ ${endDate}`; + + return ( + + + + {title} + + + + {content} + + 누적 참여자 수 : {participantCount}명 + + + + + 참여 가능 기간 + + {date} + + + ); +}; + +export default Contents; + +const ContentsBox = styled(Box)<{ isClicked: boolean }>` + width: 100%; + height: 100%; + background-color: ${({ isClicked }) => + isClicked ? 'var(--color-green-06)' : 'var(--color-green-01)'}; + border-radius: 1.2rem; + padding: 1rem; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + text-align: left; + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1.5rem; + cursor: pointer; +`; + +const FlexBox = styled(Box)` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const TextItem = styled(Text)<{ isClicked: boolean }>` + color: ${({ isClicked }) => (isClicked ? '#000' : '#fff')}; + font-size: 1rem; +`; diff --git a/src/pages/challenge-list/index.tsx b/src/pages/challenge-list/index.tsx new file mode 100644 index 0000000..5452826 --- /dev/null +++ b/src/pages/challenge-list/index.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react'; + +import Contents from './components/contents'; +import { useGetChallengeList } from '@/apis/challenge-list/getChallengeList.api'; +import { Tab, Tabs } from '@/components/common/tabs'; +import { TabPanel } from '@/components/common/tabs/tap-panels'; +import TopBar from '@/components/features/layout/top-bar'; +import { Box, Spinner } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +type Challenge = { + id: number; + title: string; + content: string; + participantCount: number; + category: 'HEALTH' | 'ECHO' | 'SHARE' | 'VOLUNTEER' | 'ETC'; + startDate: string; + endDate: string; +}; + +const ChallengeList = () => { + const [activeTab, setActiveTab] = useState(0); + const [allData, setAllData] = useState([]); + const [page, setPage] = useState(0); + + const categoryList = [ + { label: '건강', data: 'HEALTH' }, + { label: '에코', data: 'ECHO' }, + { label: '나눔', data: 'SHARE' }, + { label: '봉사', data: 'VOLUNTEER' }, + ]; + + const { data, isLoading } = useGetChallengeList(page, 20); + + useEffect(() => { + if (data) { + setAllData((prevData) => [...prevData, ...data.data.data]); + } + }, [data]); + + const handleSelectedTab = (value: number) => { + setActiveTab(value); + sessionStorage.setItem('activeTab', String(value)); + }; + + const filteredData = allData.filter( + (item) => item.category === categoryList[activeTab].data + ); + + const loadNextPage = useCallback(() => { + if (data?.data.hasNext && !isLoading) { + setPage((prevPage) => prevPage + 1); + } + }, [data, isLoading]); + + useEffect(() => { + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop !== + document.documentElement.offsetHeight + ) + return; + loadNextPage(); + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [loadNextPage]); + + return ( + <> + + + + {categoryList.map((category, index) => ( + + ))} + + + {categoryList.map((_, index) => ( + + <> + {filteredData.map((challenge) => ( + + ))} + + + ))} + + {isLoading && } + + + ); +}; + +export default ChallengeList; + +const ChallengeListLayout = styled.div` + display: block; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + margin: 0 1.5rem; + padding-bottom: 6rem; +`; + +const TabPanelsLayout = styled(Box)` + margin: 1rem 0; +`; diff --git a/src/pages/challenge-record/index.tsx b/src/pages/challenge-record/index.tsx index 0bcf751..59cdb64 100644 --- a/src/pages/challenge-record/index.tsx +++ b/src/pages/challenge-record/index.tsx @@ -2,69 +2,51 @@ import { useState } from 'react'; import StampBoard from './components/stamp-board'; import Verification from './components/verification'; -import { Tab, TabPanel, Tabs } from '@/components/common/tab'; +import { Tabs, Tab } from '@/components/common/tabs'; +import { TabPanels, TabPanel } from '@/components/common/tabs/tab-panels'; import TopBar from '@/components/features/layout/top-bar'; import styled from '@emotion/styled'; -type TabsContainerProps = { - position?: string; -}; - const ChallengeRecord = () => { - const [activeTab, setActiveTab] = useState<0 | 1>(0); - - const handleTab = (value: number) => { + const [activeTab, setActiveTab] = useState(0); + const tabsList = [ + { + label: '인증 기록', + panel: , + }, + { + label: '인증하기', + panel: , + }, + ]; + + const handleSelectedTab = (value: number) => { setActiveTab(value as 0 | 1); }; return ( <> - - - handleTab(value)}> - - - - - - - - - - - - - + + + {tabsList.map((t, index) => ( + + ))} + + + {tabsList.map((t, index) => ( + + {t.panel ?? undefined} + + ))} + + ); }; export default ChallengeRecord; -const ChallengeRecordLayout = styled.div` - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - margin: 0 1.5rem; -`; - -const TabsContainer = styled.div` - display: flex; - align-self: center; - width: 100%; - margin: 1rem auto; - height: 55px; - border-radius: 20px; - background-color: var(--color-green-06); -`; - -const TabPanelContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; - height: 100%; +const Wrapper = styled.div` width: 100%; - text-align: center; `; diff --git a/src/pages/review-write/index.tsx b/src/pages/review-write/index.tsx index bd62318..a0dc546 100644 --- a/src/pages/review-write/index.tsx +++ b/src/pages/review-write/index.tsx @@ -1,217 +1,259 @@ -import { useState } from 'react'; -import { PiStarFill, PiStarLight } from 'react-icons/pi'; -import { useNavigate } from 'react-router-dom'; - -import { Button } from 'antd'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { postReview } from '@/apis/review/review.api'; +import { StarRating } from '@/components/common/star-rating'; import TopBar from '@/components/features/layout/top-bar'; import { useChallengeStore } from '@/store/useChallengeStore'; +import { + formatRating, + formatDifficulty, + formatAchievement, +} from '@/utils/formatters'; import { Box, Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; -const SAMPLE_CHALLENGE_ID = 1; - const ReviewWrite = () => { + const { id } = useParams(); + const challengeId = Number(id); + // const challengeGrouptitle = sessionStorage.getItem('challengeGroupTitle'); + const categoryLabel = sessionStorage.getItem('categoryLabel'); const { challengeTitle } = useChallengeStore(); - - const difficultyList = ['쉬워요', '적당해요', '어려워요']; - const feelingList = ['뿌듯해요', '유익해요', '애매해요']; - - const navigate = useNavigate(); + // const challengeGroupTitle = sessionStorage.getItem('challengeGroupTitle'); const [rating, setRating] = useState(0); + const difficultyList = [1, 2, 3]; const [selectedDifficulty, setSelectedDifficulty] = useState< - string | undefined + number | undefined + >(); + const achievementList = [1, 2, 3]; + const [selectedAchievement, setSelectedAchievement] = useState< + number | undefined >(); - const [selectedFeeling, setSelectedFeeling] = useState(); const [content, setContent] = useState(''); + const [isContentValid, setIsContentValid] = useState(true); + const [isButtonDisabled, setIsButtonDisabled] = useState(true); - const handleDifficultyClick = (difficulty: string) => { + const handleDifficultyClick = (difficulty: number) => { setSelectedDifficulty(difficulty); }; - const handleFeelingClick = (feeling: string) => { - setSelectedFeeling(feeling); + const handleFeelingClick = (feeling: number) => { + setSelectedAchievement(feeling); }; - const handleSaveReview = () => { - if (rating === 0) { - alert('별점을 선택해주세요.'); - return; - } else if ( - selectedDifficulty === undefined || - selectedFeeling === undefined + // 별점, 체감 난이도, 성취감, 내용 유효성 검사 -> 버튼 상태 관리 + useEffect(() => { + if ( + rating && + selectedDifficulty && + selectedAchievement && + content.trim() && + content.length >= 20 ) { - alert('난이도와 성취감을 선택해주세요.'); - return; - } else if (!content.trim()) { - alert('리뷰 내용을 입력해주세요.'); - return; - } else - postReview({ - challengeId: SAMPLE_CHALLENGE_ID, - content, - rating, + setIsButtonDisabled(false); + } else { + setIsButtonDisabled(true); + } + }, [rating, selectedDifficulty, selectedAchievement, content]); + + // 소감 내용 유효성 검사 + const handleContentChange = (e: React.ChangeEvent) => { + const newContent = e.target.value; + setContent(newContent); + + if (newContent.trim() && newContent.length >= 20) { + setIsContentValid(true); + } else { + setIsContentValid(false); + } + }; + + const handleSaveReview = () => { + postReview({ + challengeId, + content, + rating, + difficulty: selectedDifficulty, + achievement: selectedAchievement, + }) + .then(() => { + alert('리뷰가 등록되었습니다!'); }) - .then(() => { - alert('성공적으로 저장했습니다.'); - navigate('/'); - }) - .catch(() => { - alert('저장에 실패했습니다.'); - }); + .catch((error) => { + // API에서 받은 오류 객체일 경우 + if (error.result === 'FAIL') { + alert(error.message || '다시 시도해주세요.'); + } + // 예상치 못한 오류 처리 + else { + alert('다시 시도해주세요.'); + } + }); }; return ( <> - - - - {challengeTitle} - - - {[...Array(rating)].map((_, i) => ( - setRating(i + 1)} - color='var(--color-green-01)' - /> - ))} - {[...Array(5 - rating)].map((_, i) => ( - setRating(rating + i + 1)} - color='var(--color-green-01)' + + + + {categoryLabel} + {challengeTitle} + + + + setRating(newRating)} /> - ))} - {rating}.0 - - /5.0 + + {rating}.0 / 5.0 + + + + + {formatRating(rating)} + + + + + + 체감 난이도 - - - - 난이도 + + {difficultyList.map((d) => ( + handleDifficultyClick(d)} + isSelected={selectedDifficulty === d} + > + {formatDifficulty(d)} + + ))} + + + + + 성취감 - {difficultyList.map((item) => ( - handleDifficultyClick(item)} - isSelected={selectedDifficulty === item} - > - {item} - - ))} - - + + {achievementList.map((a) => ( + handleFeelingClick(a)} + isSelected={selectedAchievement === a} + > + {formatAchievement(a)} + + ))} + + + + + 소감 + + - 성취감 + {content.length} / 최소 20자 - {feelingList.map((item) => ( - handleFeelingClick(item)} - isSelected={selectedFeeling === item} - > - {item} - - ))} - - - 리뷰 쓰기 - - setContent(e.target.value)} - /> - 등록하기 - + + + + 리뷰 작성 시 주의 사항 + + + 해당 챌린지와 무관한 내용 또는 욕설, 도배 등의{' '} + + 부적절한 내용은 삭제 조치 + + 될 수 있습니다. + + + + + + 등록하기 + + ); }; export default ReviewWrite; -const ReviewWriteLayout = styled.div` +const Wrapper = styled.div` position: relative; - margin: 30px; - margin-bottom: 60px; display: flex; flex-direction: column; text-align: left; + margin-bottom: 3.44rem; + gap: 16px; `; -const Wrapper = styled(Box)` +const ChallengeTitleWrapper = styled.div` + margin: 16px; display: flex; - flex-direction: row; + flex-direction: column; + text-align: left; `; -const Star = styled.div` - font-size: var(--font-size-xxl); +const Category = styled.div` + font-size: var(--font-size-xs); + color: var(--color-green-01); +`; + +const Title = styled.div` + font-size: var(--font-size-xl); font-weight: bold; - margin-left: 10px; `; -const InputArea = styled.textarea` - font-size: var(--font-size-sm); - border-radius: 20px; - border: var(--color-green-01) 1px solid; - padding: 10px; - height: 30vh; - resize: none; - margin-top: 10px; +const FlexBox = styled(Box)` + display: flex; + padding: 0 16px; `; -const SubmitButton = styled(Button)` - position: fixed; - display: block; - bottom: 60px; - width: calc(100% - 60px); - height: 50px; - margin-top: 30px; - border-radius: 20px; - background-color: var(--color-green-01); - color: var(--color-white); - font-size: var(--font-size-md); - font-weight: bold; - border: none; +const Rating = styled.span` + display: inline-flex; + align-items: end; + + span:first-child { + font-size: var(--font-size-xxl); + font-weight: bold; + margin-left: 10px; + line-height: 1; + } + + span:last-child { + font-size: var(--font-size-sm); + color: var(--color-gray-01); + } `; -const CheckButton = styled.button<{ isSelected: boolean }>` - height: 25px; - border-radius: 20px; - margin-left: 5px; - width: 4rem; +const Chip = styled.button<{ isSelected: boolean }>` + margin-right: 4px; + padding: 4px 12px; + border-radius: 50px; border: var(--color-grey-02) 1px solid; background-color: var(--color-white); - color: var(--color-grey-02); + color: var(--color-grey-01); font-size: var(--font-size-sm); + font-weight: 600; text-align: center; - flex-shrink: 0; ${({ isSelected }) => isSelected && ` @@ -219,3 +261,64 @@ const CheckButton = styled.button<{ isSelected: boolean }>` color: var(--color-green-01); `} `; + +const Content = styled.textarea<{ valid?: boolean }>` + font-size: var(--font-size-sm); + color: var(--color-black); + border-radius: 20px; + border: ${({ valid }) => + valid + ? 'var(--color-grey-02) 1px solid' + : 'var(--color-class-05) 1px solid'}; + padding: 12px; + width: 100%; + height: 180px; + resize: none; + outline: none; + + &::placeholder { + color: var(--color-grey-01); + opacity: 1; /* Firefox에서 placeholder 색상을 명시적으로 설정하기 위해 추가 */ + } + + &:focus { + border: ${({ valid }) => + valid + ? 'var(--color-green-01) 1px solid' + : 'var(--color-class-05) 1px solid'}; + } +`; + +const CTABox = styled(Box)` + position: fixed; + bottom: 3.44rem; // 밑에 탭바 + display: flex; + width: 100%; + height: 3.44rem; + padding: 4px 16px; +`; + +const SubmitButton = styled.button<{ disabled?: boolean }>` + width: 100%; + height: 100%; + border: none; + border-radius: 10px; + background-color: var(--color-green-01); + color: var(--color-white); + font-size: var(--font-size-md); + font-weight: bold; + outline: none; + + &:disabled { + cursor: not-allowed; + color: var(--color-grey-01); + background-color: var(--color-grey-02); + } + + &:focus, + &:hover { + opacity: 0.8 !important; + background-color: var(--color-green-01) !important; + color: var(--color-white) !important; + } +`; diff --git a/src/pages/review/components/review-item/index.tsx b/src/pages/review/components/review-item/index.tsx index 97487e0..4247144 100644 --- a/src/pages/review/components/review-item/index.tsx +++ b/src/pages/review/components/review-item/index.tsx @@ -1,6 +1,13 @@ import { type ReviewData } from '@/apis/review/review.response'; +import { Chip } from '@/components/common/chip'; +import { ProfileImage } from '@/components/common/profile-image'; import { StarRating } from '@/components/common/star-rating'; -import { Box, Image, Text } from '@chakra-ui/react'; +import { + formatAchievement, + formatDate, + formatDifficulty, +} from '@/utils/formatters'; +import { Box, Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; type ReviewItemProps = { @@ -10,26 +17,46 @@ type ReviewItemProps = { const ReviewItem = ({ item }: ReviewItemProps) => { const rating = item.rating; + const formattedDate = formatDate(item.createdAt); + const formattedDifficulty = formatDifficulty(item.difficulty); + const formattedAchievement = formatAchievement(item.achievement); + return ( - - - + - - {item.user.nickname} + + + {item.user.nickname} + {item.user.tierInfo.tier} - 난이도 {item.challengeDifficulty} + + 신고 + + + + {formattedDate} + + + 난이도 {item.challengeDifficulty} + + + + {formattedDifficulty} + {formattedAchievement} {item.content} @@ -42,26 +69,14 @@ export default ReviewItem; const Wrapper = styled(Box)` display: flex; text-align: left; - gap: 8px; + gap: 16px; flex: 1; width: 100%; `; -const ImageBox = styled.div` - height: 2rem; - width: 2rem; - border-radius: 50%; - overflow: hidden; - aspect-ratio: 1/1; - display: flex; /* 이미지 가운데 정렬 */ - align-items: center; - justify-content: center; -`; - const ReviewItemBox = styled(Box)` display: flex; flex-direction: column; - gap: 8px; width: 100%; `; @@ -72,12 +87,7 @@ const RowWrapper = styled(Wrapper)` gap: 8px; `; -const Rating = styled.div` - font-size: var(--font-size-xs); - color: var(--color-grey-01); - border-radius: 20px; - border: var(--color-grey-02) 0.5px solid; - padding: 2px 8px; - text-align: center; - margin-left: auto; +const VerticalLine = styled.span` + height: 10px; + border-right: 0.5px solid var(--color-grey-02); `; diff --git a/src/pages/review/components/review-rating/index.tsx b/src/pages/review/components/review-rating/index.tsx index 5f1e275..9c9710c 100644 --- a/src/pages/review/components/review-rating/index.tsx +++ b/src/pages/review/components/review-rating/index.tsx @@ -3,6 +3,11 @@ import { useState, useEffect } from 'react'; import { getChallegeAvgScore } from '@/apis/review/review.api'; import type { RatingCount } from '@/apis/review/review.response'; import { StarRating } from '@/components/common/star-rating'; +import { + formatRating, + formatToFixed, + formatWithComma, +} from '@/utils/formatters'; import { Box, Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; @@ -18,20 +23,24 @@ const ReviewRating = ({ challengeGroupId }: ReviewDataProps) => { 2: 0, 1: 0, }); - const [avgRating, setAvgRating] = useState(0); + const [avgRating, setAvgRating] = useState(0); + const [formattedAvgRating, setFormattedAvgRating] = useState('0.0'); // 소수점 한 자리까지 포맷팅된 별점 평균 const [totalRatings, setTotalRatings] = useState(0); // 별점(리뷰) 개수 + const [formattedTotalRatings, setFormattedTotalRatings] = useState(''); // 쉼표 포맷팅된 별점(리뷰) 개수 useEffect(() => { getChallegeAvgScore({ challengeGroupId: challengeGroupId }).then((res) => { setRatingCount(res.ratingCount); - setAvgRating(Number(res.averageRating.toFixed(1))); // 소수점 아래 한 자리 + setAvgRating(res.averageRating); + setFormattedAvgRating(formatToFixed(res.averageRating)); - // 모든 별점 개수 합 구하기 + // 모든 별점 개수 합 구하여 저장 const total = Object.values(res.ratingCount).reduce( (acc, value) => acc + value, 0 ); setTotalRatings(total); + setFormattedTotalRatings(formatWithComma(total)); }); }, [challengeGroupId]); @@ -42,10 +51,14 @@ const ReviewRating = ({ challengeGroupId }: ReviewDataProps) => { return ( + + {formattedTotalRatings || 0}개의 리뷰 + + - {avgRating} + {formattedAvgRating} @@ -58,15 +71,7 @@ const ReviewRating = ({ challengeGroupId }: ReviewDataProps) => { color='var(--color-grey-01)' mr='auto' > - {key === '5' - ? '매우 만족' - : key === '4' - ? '만족' - : key === '3' - ? '보통' - : key === '2' - ? '별로' - : '매우 별로'} + {formatRating(Number(key))} { ))} - - {totalRatings}개의 리뷰 ); }; @@ -90,7 +93,7 @@ export default ReviewRating; const Wrapper = styled(Box)` display: flex; flex-direction: column; - align-items: flex-end; + align-items: flex-start; margin: 0 16px; gap: 8px; `; diff --git a/src/pages/review/index.tsx b/src/pages/review/index.tsx index 361cf49..5867b05 100644 --- a/src/pages/review/index.tsx +++ b/src/pages/review/index.tsx @@ -63,7 +63,7 @@ const Review = () => {
{index < reviewList.length - 1 && ( - + )} {/* 마지막 요소 뒤에는 Line을 넣지 않음 */}
@@ -71,15 +71,17 @@ const Review = () => { ) : ( // 리뷰 없을 때 - - 아직 리뷰가 없습니다. -
- 챌린지를 완료하고{' '} - - 첫 번째 리뷰어 + + + 아직 리뷰가 없습니다. +
+ 챌린지를 완료하고{' '} + + 첫 번째 리뷰어 + + 가 되어보세요!
- 가 되어보세요! -
+ )} {isFetching ? '로딩 중...' : ' '}
@@ -100,6 +102,7 @@ const Wrapper = styled.div` display: flex; flex-direction: column; text-align: center; + margin-bottom: 3.44rem; `; const Title = styled.div` @@ -114,5 +117,9 @@ const ReviewList = styled.div` display: flex; flex-direction: column; padding: 16px 0; - margin: 0 16px 3.44rem 16px; + margin: 0 16px 0 16px; +`; + +const EmptyState = styled.div` + padding: 16px 16px; `; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index cc6e2e7..a16cf8f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,6 +4,7 @@ import { ProtectedRoute } from './protected-route'; import NavBar from '@/components/features/layout/nav-bar'; import ErrorPage from '@/pages/ErrorPage'; import ChallengeDetailPage from '@/pages/challenge-detail'; +import ChallengeList from '@/pages/challenge-list'; import ChallengeRecord from '@/pages/challenge-record'; import DashBoardPage from '@/pages/dashboard'; import LoginPage from '@/pages/login'; @@ -58,6 +59,14 @@ const router = createBrowserRouter([ ), }, + { + path: RouterPath.challengeList, + element: ( + + + + ), + }, { path: `:id/${RouterPath.detail}`, element: ( @@ -73,16 +82,14 @@ const router = createBrowserRouter([ ), - children: [ - { - path: RouterPath.write, - element: ( - - - - ), - }, - ], + }, + { + path: `:id/${RouterPath.write}`, + element: ( + + + + ), }, ], }, diff --git a/src/routes/path.ts b/src/routes/path.ts index fd20627..b55e80a 100644 --- a/src/routes/path.ts +++ b/src/routes/path.ts @@ -15,4 +15,5 @@ export const RouterPath = { register: 'register', review: 'review', write: 'write', + challengeList: 'list', }; diff --git a/src/routes/protected-route.tsx b/src/routes/protected-route.tsx index 30c44c3..c5827b9 100644 --- a/src/routes/protected-route.tsx +++ b/src/routes/protected-route.tsx @@ -1,5 +1,5 @@ import { ReactNode, useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { RouterPath } from './path'; @@ -10,23 +10,22 @@ interface ProtectedRouteProps { export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const accessToken = localStorage.getItem('accessToken'); const navigate = useNavigate(); - const location = useLocation(); // accessToken이 없으면 로그인페이지로 리다이렉트, 있으면 자식 요소(페이지) 렌더링 useEffect(() => { if (!accessToken) { alert('로그인 후 이용해주세요.'); - navigate(getDynamicPath.login(location.pathname)); // host 빼고 경로만 + navigate(getDynamicPath.login()); } - }, [accessToken, location.pathname, navigate]); + }, [accessToken, navigate]); return accessToken ? <>{children} : null; }; // 로그인 필요한 페이지라면 로그인 페이지로 리다이렉트, 로그인 완료 시 원래 페이지로 돌아가는 함수 -const getDynamicPath = { +export const getDynamicPath = { login: (redirect?: string) => { - const currentRedirect = redirect ?? window.location.href; + const currentRedirect = redirect ?? window.location.pathname; // host 뺀 경로(pathname)로 접근 return `/${RouterPath.auth}?redirect=${encodeURIComponent(currentRedirect)}`; }, }; diff --git a/src/styles/baseStyles.ts b/src/styles/baseStyles.ts index 5d7fa15..bca6a1d 100644 --- a/src/styles/baseStyles.ts +++ b/src/styles/baseStyles.ts @@ -109,16 +109,19 @@ export const TextItem = styled.div<{ fontWeight?: string; color: string }>` line-height: normal; `; -export const HorizontalLine = styled.div<{ margin?: number }>` +export const HorizontalLine = styled.div<{ + marginX?: number; + marginY?: number; +}>` border-top: 1px solid var(--color-green-06); - margin: ${({ margin }) => margin && `${margin}px 0`}; + margin: ${({ marginX = 0, marginY = 0 }) => `${marginY}px ${marginX}px`}; `; -export const VerticalLine = styled.div<{ margin?: number }>` +export const VerticalLine = styled.div<{ marginX?: number; marginY?: number }>` border: 1px solid var(--color-green-06); /* flex: 1; */ height: 100px; // 수정 필요 - margin: ${({ margin }) => margin && `0 ${margin}px`}; + margin: ${({ marginX = 0, marginY = 0 }) => `${marginY}px ${marginX}px`}; `; // export const TeerTotal = styled.div` diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..2b180bc --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,92 @@ +export const formatDate = (dateStr: string): string => { + const dateObj = new Date(dateStr); + + return `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`; + // 2024.00.00 형식으로 반환 +}; + +// 숫자에 소수점 처리 +export const formatToFixed = (number: number, digits = 1) => { + return number.toFixed(digits); +}; + +// 숫자에 , 넣기 +export const formatWithComma = (number: number) => { + return number.toLocaleString(); +}; + +// 챌린지 카테고리 +export const formatCategory = (category: string | undefined): string => { + switch (category) { + case 'HEALTH': + return '건강'; + case 'ECHO': + return '에코'; + case 'SHARE': + return '나눔'; + case 'VOLUNTEER': + return '봉사'; + case 'ETC': + return '기타'; + default: + return ''; + } +}; + +// const categoryList = [ +// { label: '건강', data: 'HEALTH' }, +// { label: '에코', data: 'ECHO' }, +// { label: '나눔', data: 'SHARE' }, +// { label: '봉사', data: 'VOLUNTEER' }, +// { label: '기타', data: 'ETC' }, +// ]; + +// // data.category에 맞는 label 찾기 +// const categoryLabel = +// categoryList.find((c) => c.data === data?.category)?.label || ''; + +// 리뷰 - 별점 +export const formatRating = (rating: number): string => { + switch (rating) { + case 5: + return '최고예요 😆'; + case 4: + return '만족해요 😀'; + case 3: + return '무난해요 🙂'; + case 2: + return '그저 그래요 😐'; + case 1: + return '별로예요 🙁'; + default: + return ''; + } +}; + +// 리뷰 - 체감 난이도 +export const formatDifficulty = (difficulty: number): string => { + switch (difficulty) { + case 1: + return '쉬워요'; + case 2: + return '적당해요'; + case 3: + return '어려워요'; + default: + return ''; + } +}; + +// 리뷰 - 성취감 +export const formatAchievement = (achievement: number): string => { + switch (achievement) { + case 1: + return '뿌듯해요'; + case 2: + return '보통이에요'; + case 3: + return '잘 모르겠어요'; + default: + return ''; + } +};