diff --git a/frontend/src/components/ReviewCard/index.tsx b/frontend/src/components/ReviewCard/index.tsx index f86d8c395..f820a55a4 100644 --- a/frontend/src/components/ReviewCard/index.tsx +++ b/frontend/src/components/ReviewCard/index.tsx @@ -10,24 +10,21 @@ interface ReviewCardProps { handleClick: () => void; } -const ReviewCard = ({ projectName, createdAt, contentPreview, categories, handleClick }: ReviewCardProps) => { +const ReviewCard = ({ createdAt, contentPreview, categories, handleClick }: ReviewCardProps) => { return ( - - - - {projectName} - {createdAt} - - - + + {contentPreview} - - {categories.map((category) => ( - {category.content} - ))} - + + + {categories.map((category) => ( + {category.content} + ))} + + {createdAt} + ); diff --git a/frontend/src/components/ReviewCard/styles.ts b/frontend/src/components/ReviewCard/styles.ts index afa98cf55..cabca6485 100644 --- a/frontend/src/components/ReviewCard/styles.ts +++ b/frontend/src/components/ReviewCard/styles.ts @@ -4,13 +4,12 @@ import media from '@/utils/media'; export const Layout = styled.div` display: flex; - flex-direction: column; border: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; - border-radius: 0.8rem; + border-radius: 1rem; &:hover { cursor: pointer; - border: 0.1rem solid ${({ theme }) => theme.colors.lightPurple}; + border: 0.15rem solid ${({ theme }) => theme.colors.primaryHover}; & > div:first-of-type { background-color: ${({ theme }) => theme.colors.lightPurple}; @@ -18,24 +17,10 @@ export const Layout = styled.div` } `; -export const Header = styled.div` - display: flex; - justify-content: space-between; - - height: 6rem; - padding: 1rem 3rem; - +export const LeftLineBorder = styled.div` + width: 5rem; background-color: ${({ theme }) => theme.colors.lightGray}; - border-radius: 0.8rem 0.8rem 0 0; -`; - -export const HeaderContent = styled.div` - display: flex; - gap: 1rem; - - img { - width: 4rem; - } + border-radius: 1rem 0 0 1rem; `; export const Title = styled.div` @@ -43,8 +28,11 @@ export const Title = styled.div` font-weight: 700; `; -export const SubTitle = styled.div` - font-size: 1.2rem; +export const Date = styled.p` + height: fit-content; + padding: 0 1rem; + font-size: 1.3rem; + background-color: ${({ theme }) => theme.colors.lightGray}; `; export const Visibility = styled.div` @@ -74,6 +62,18 @@ export const Main = styled.div` } `; +export const Footer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + ${media.small} { + flex-direction: column; + gap: 1.2rem; + align-items: flex-start; + } +`; + export const Keyword = styled.div` display: flex; flex-wrap: wrap; @@ -83,7 +83,7 @@ export const Keyword = styled.div` font-size: 1.4rem; ${media.small} { - gap: 1.6rem; + gap: 1.2rem; } div { diff --git a/frontend/src/components/common/OptionSwitch/index.tsx b/frontend/src/components/common/OptionSwitch/index.tsx new file mode 100644 index 000000000..7b85b495e --- /dev/null +++ b/frontend/src/components/common/OptionSwitch/index.tsx @@ -0,0 +1,36 @@ +import * as S from './styles'; + +export interface OptionSwitchStyleProps { + $isChecked: boolean; +} + +export interface OptionSwitchOption { + label: string; + isChecked: boolean; + handleOptionClick: () => void; +} + +interface OptionSwitchProps { + options: OptionSwitchOption[]; +} + +const OptionSwitch = ({ options }: OptionSwitchProps) => { + const handleSwitchClick = (index: number) => { + const clickedOption = options[index]; + if (clickedOption) clickedOption.handleOptionClick(); + }; + + return ( + + {options.map((option, index) => ( + handleSwitchClick(index)}> + + {option.label} + + + ))} + + ); +}; + +export default OptionSwitch; diff --git a/frontend/src/components/common/OptionSwitch/styles.ts b/frontend/src/components/common/OptionSwitch/styles.ts new file mode 100644 index 000000000..fb7d0fddb --- /dev/null +++ b/frontend/src/components/common/OptionSwitch/styles.ts @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +import { OptionSwitchStyleProps } from './index'; + +export const OptionSwitchContainer = styled.ul` + cursor: pointer; + + display: flex; + justify-content: space-between; + + width: 15rem; + height: 4.5rem; + padding: 0.7rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + ${media.small} { + height: 3.5rem; + font-size: 1.2rem; + } +`; + +export const CheckboxWrapper = styled.li` + display: flex; + align-items: center; + justify-content: center; + + width: 50%; + height: 100%; + + background-color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.white : theme.colors.lightGray)}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + transition: background-color 0.2s ease-out; + + &:hover { + background-color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.white : theme.colors.lightPurple)}; + } +`; + +export const CheckboxButton = styled.button` + user-select: none; + font-size: 1.2rem; + color: ${({ $isChecked, theme }) => ($isChecked ? theme.colors.primary : theme.colors.black)}; +`; diff --git a/frontend/src/components/common/index.tsx b/frontend/src/components/common/index.tsx index f18308ab9..48218fd2f 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -7,4 +7,5 @@ export { default as Checkbox } from './Checkbox'; export { default as CheckboxItem } from './CheckboxItem'; export { default as EyeButton } from './EyeButton'; export { default as Carousel } from './Carousel'; +export { default as OptionSwitch } from './OptionSwitch'; export * from './modals'; diff --git a/frontend/src/components/layouts/PageLayout/index.tsx b/frontend/src/components/layouts/PageLayout/index.tsx index 4987e495e..34bba823c 100644 --- a/frontend/src/components/layouts/PageLayout/index.tsx +++ b/frontend/src/components/layouts/PageLayout/index.tsx @@ -1,4 +1,3 @@ -import { TopButton } from '@/components/common'; import Breadcrumb from '@/components/common/Breadcrumb'; import useBreadcrumbPaths from '@/hooks/useBreadcrumbPaths'; import { EssentialPropsWithChildren } from '@/types'; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx new file mode 100644 index 000000000..d1bf463bf --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/index.tsx @@ -0,0 +1,35 @@ +import { calculateParticle } from '@/utils'; + +import * as S from './styles'; + +export interface ReviewInfoSectionProps { + revieweeName: string; + isReviewList: boolean; + projectName: string; + reviewCount?: number; +} + +const ReviewInfoSection = ({ projectName, revieweeName, reviewCount, isReviewList }: ReviewInfoSectionProps) => { + const revieweeNameWithParticle = calculateParticle({ + target: revieweeName, + particles: { withFinalConsonant: '이', withoutFinalConsonant: '가' }, + }); + + const getReviewInfoMessage = () => { + return isReviewList + ? `${revieweeNameWithParticle} 받은 ${reviewCount}개의 리뷰 목록이에요` + : `${revieweeNameWithParticle} 받은 리뷰를 질문별로 모아봤어요`; + }; + + return ( + + {projectName} + + {revieweeName} + {getReviewInfoMessage()} + + + ); +}; + +export default ReviewInfoSection; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/styles.ts similarity index 91% rename from frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts rename to frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/styles.ts index d558c685a..b84fe2ce3 100644 --- a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts +++ b/frontend/src/components/layouts/ReviewDisplayLayout/components/ReviewInfoSection/styles.ts @@ -5,10 +5,11 @@ import media from '@/utils/media'; export const ReviewInfoContainer = styled.div` display: flex; flex-direction: column; - margin: 2rem 0 3rem 1rem; + justify-content: flex-end; + margin: 2rem 0 3rem 0; ${media.small} { - margin-bottom: 1.8rem; + margin-bottom: 1rem; } `; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts new file mode 100644 index 000000000..61c3583fd --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/hooks/useReviewDisplayLayoutOptions.ts @@ -0,0 +1,33 @@ +import { useLocation, useNavigate } from 'react-router'; + +import { OptionSwitchOption } from '@/components/common/OptionSwitch'; +import { ROUTE } from '@/constants/route'; +import { useSearchParamAndQuery } from '@/hooks'; + +const useReviewDisplayLayoutOptions = () => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const { param: reviewRequestCode } = useSearchParamAndQuery({ + paramKey: 'reviewRequestCode', + }); + + const isReviewCollection = pathname.includes(ROUTE.reviewCollection); + + const reviewDisplayLayoutOptions: OptionSwitchOption[] = [ + { + label: '목록보기', + isChecked: !isReviewCollection, + handleOptionClick: () => navigate(`/${ROUTE.reviewList}/${reviewRequestCode}`), + }, + { + label: '모아보기', + isChecked: isReviewCollection, + handleOptionClick: () => navigate(`/${ROUTE.reviewCollection}/${reviewRequestCode}`), + }, + ]; + + return [...reviewDisplayLayoutOptions]; +}; + +export default useReviewDisplayLayoutOptions; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx new file mode 100644 index 000000000..8c22cfbfc --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/index.tsx @@ -0,0 +1,34 @@ +import { TopButton, OptionSwitch } from '@/components/common'; +import { EssentialPropsWithChildren } from '@/types'; + +import ReviewInfoSection, { ReviewInfoSectionProps } from './components/ReviewInfoSection'; +import useReviewDisplayLayoutOptions from './hooks/useReviewDisplayLayoutOptions'; +import * as S from './styles'; + +const ReviewDisplayLayout = ({ + revieweeName, + projectName, + reviewCount, + isReviewList, + children, +}: EssentialPropsWithChildren) => { + const reviewDisplayLayoutOptions = useReviewDisplayLayoutOptions(); + + return ( + + + + + + + {children} + + ); +}; + +export default ReviewDisplayLayout; diff --git a/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts new file mode 100644 index 000000000..2cbf1e1bb --- /dev/null +++ b/frontend/src/components/layouts/ReviewDisplayLayout/styles.ts @@ -0,0 +1,20 @@ +import styled from '@emotion/styled'; + +export const ReviewDisplayLayout = styled.div` + display: flex; + flex-direction: column; + width: 90%; + min-height: inherit; +`; + +export const Container = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + @media screen and (max-width: 500px) { + flex-direction: column; + align-items: flex-start; + margin-bottom: 2.5rem; + } +`; diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index d79e9aed8..0826e9ff8 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -6,4 +6,5 @@ export const ROUTE = { reviewWritingComplete: 'user/review-writing-complete', detailedReview: 'user/detailed-review', reviewZone: 'user/review-zone', + reviewCollection: 'user/review-collection', }; diff --git a/frontend/src/hooks/useBreadcrumbPaths.ts b/frontend/src/hooks/useBreadcrumbPaths.ts index a36424979..071cbfbf0 100644 --- a/frontend/src/hooks/useBreadcrumbPaths.ts +++ b/frontend/src/hooks/useBreadcrumbPaths.ts @@ -17,25 +17,32 @@ const useBreadcrumbPaths = () => { paramKey: ROUTE_PARAM.reviewId, }); - const breadcrumbPathList: Path[] = [{ pageName: '연결 페이지', path: `${ROUTE.reviewZone}/${reviewRequestCode}` }]; + const breadcrumbPathList: Path[] = [{ pageName: '리뷰 연결', path: `${ROUTE.reviewZone}/${reviewRequestCode}` }]; if (pathname === `/${ROUTE.reviewList}/${reviewRequestCode}`) { - breadcrumbPathList.push({ pageName: '목록 페이지', path: `${ROUTE.reviewList}/${reviewRequestCode}` }); + breadcrumbPathList.push({ pageName: '리뷰 목록', path: `${ROUTE.reviewList}/${reviewRequestCode}` }); + } + + if (pathname === `/${ROUTE.reviewCollection}/${reviewRequestCode}`) { + breadcrumbPathList.push({ pageName: '리뷰 모아보기', path: `${ROUTE.reviewCollection}/${reviewRequestCode}` }); } if (pathname.includes(`/${ROUTE.reviewWriting}/`)) { - breadcrumbPathList.push({ pageName: '작성 페이지', path: pathname }); + breadcrumbPathList.push({ pageName: '리뷰 작성', path: pathname }); } if (pathname.includes(`/${ROUTE.reviewWritingComplete}`)) { - breadcrumbPathList.push({ pageName: '작성 페이지', path: -1 }, { pageName: '작성 완료 페이지', path: pathname }); + breadcrumbPathList.push( + { pageName: '리뷰 작성', path: `${ROUTE.reviewWriting}/${reviewRequestCode}` }, + { pageName: '리뷰 작성 완료 페이지', path: pathname }, + ); } if (pathname.includes(ROUTE.detailedReview)) { breadcrumbPathList.push( - { pageName: '목록 페이지', path: `${ROUTE.reviewList}/${reviewRequestCode}` }, + { pageName: '리뷰 목록', path: `${ROUTE.reviewList}/${reviewRequestCode}` }, { - pageName: '상세 페이지', + pageName: '리뷰 상세', path: `${ROUTE.detailedReview}/${reviewRequestCode}/${reviewId}`, }, ); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 9257d8998..da75f7b49 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -24,6 +24,7 @@ const ReviewListPage = lazy(() => import('@/pages/ReviewListPage')); const ReviewWritingCompletePage = lazy(() => import('@/pages/ReviewWritingCompletePage')); const ReviewWritingPage = lazy(() => import('@/pages/ReviewWritingPage')); const ReviewZonePage = lazy(() => import('@/pages/ReviewZonePage')); +const ReviewCollectionPage = lazy(() => import('@/pages/ReviewCollectionPage')); const LoadingPage = lazy(() => import('@/pages/LoadingPage')); @@ -100,6 +101,10 @@ const router = createBrowserRouter([ ), }, + { + path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, + element: , + }, ], }, ]); diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx new file mode 100644 index 000000000..6e6718d31 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -0,0 +1,25 @@ +import { AuthAndServerErrorFallback, ErrorSuspenseContainer, TopButton } from '@/components'; +import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; +import { useGetReviewList } from '@/hooks'; + +const ReviewCollectionPage = () => { + // TODO: 추후 리뷰 그룹 정보를 받아오는 API로 대체 + const { data } = useGetReviewList(); + const { revieweeName, projectName } = data.pages[0]; + + return ( + + + 리뷰 모아보기 페이지 children + {Array(50) + .fill('스크롤바 없어서 생기는 layout shift 방지용 + Topbutton 확인용 더미 데이터입니다.') + .map((data, index) => { + return {data}; + })} + + + + ); +}; + +export default ReviewCollectionPage; diff --git a/frontend/src/pages/ReviewCollectionPage/styles.ts b/frontend/src/pages/ReviewCollectionPage/styles.ts new file mode 100644 index 000000000..f404b4253 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/styles.ts @@ -0,0 +1,2 @@ +import styled from '@emotion/styled'; + diff --git a/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx b/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx index c63b68b83..04688cd79 100644 --- a/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx +++ b/frontend/src/pages/ReviewListPage/components/PageContents/index.tsx @@ -1,13 +1,13 @@ import { useNavigate } from 'react-router'; import UndraggableWrapper from '@/components/common/UndraggableWrapper'; +import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import ReviewCard from '@/components/ReviewCard'; import { ROUTE } from '@/constants/route'; import { useGetReviewList, useSearchParamAndQuery } from '@/hooks'; import { useInfiniteScroll } from '../../hooks'; import ReviewEmptySection from '../ReviewEmptySection'; -import ReviewInfoSection from '../ReviewInfoSection'; import * as S from './styles'; @@ -36,8 +36,7 @@ const PageContents = () => { return ( isSuccess && ( - - + {reviews.length === 0 ? ( ) : ( @@ -59,7 +58,7 @@ const PageContents = () => { })} )} - + ) ); }; diff --git a/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts b/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts index 641a8ccaa..08cfe137a 100644 --- a/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts +++ b/frontend/src/pages/ReviewListPage/components/PageContents/styles.ts @@ -1,12 +1,5 @@ import styled from '@emotion/styled'; -export const Layout = styled.div` - display: flex; - flex-direction: column; - width: 90%; - min-height: inherit; -`; - export const ReviewSection = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx b/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx deleted file mode 100644 index 2b3862075..000000000 --- a/frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { calculateParticle } from '@/utils'; - -import * as S from './styles'; - -interface ReviewInfoSectionProps { - projectName: string; - revieweeName: string; -} - -const ReviewInfoSection = ({ projectName, revieweeName }: ReviewInfoSectionProps) => { - const reviewMessageSuffix = `${calculateParticle({ target: revieweeName, particles: { withFinalConsonant: '이', withoutFinalConsonant: '가' } })} 받은 리뷰 목록이에요`; - - return ( - - {projectName} - - {revieweeName} - {reviewMessageSuffix} - - - ); -}; - -export default ReviewInfoSection; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 3c5f182ce..4dc586d19 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -6,3 +6,4 @@ export { default as ReviewListPage } from './ReviewListPage'; export { default as ReviewWritingPage } from './ReviewWritingPage'; export { default as ReviewWritingCompletePage } from './ReviewWritingCompletePage'; export { default as ReviewZonePage } from './ReviewZonePage'; +export { default as ReviewCollectionPage } from './ReviewCollectionPage';
{data}