diff --git a/frontend/src/assets/slideArrows.svg b/frontend/src/assets/slideArrows.svg new file mode 100644 index 000000000..acf1609d9 --- /dev/null +++ b/frontend/src/assets/slideArrows.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/ReviewListItem/index.tsx b/frontend/src/components/ReviewListItem/index.tsx new file mode 100644 index 000000000..75dacaf8f --- /dev/null +++ b/frontend/src/components/ReviewListItem/index.tsx @@ -0,0 +1,13 @@ +// 임시 컴포넌트! 작성한 리뷰 확인 && 받은 리뷰 확인 아이템 + +import * as S from './styles'; + +interface ReviewListItemProps { + handleClick: () => void; +} + +const ReviewListItem = ({ handleClick }: ReviewListItemProps) => { + return 리뷰 목록 아이템입니다; +}; + +export default ReviewListItem; diff --git a/frontend/src/components/ReviewListItem/styles.ts b/frontend/src/components/ReviewListItem/styles.ts new file mode 100644 index 000000000..cb080301b --- /dev/null +++ b/frontend/src/components/ReviewListItem/styles.ts @@ -0,0 +1,19 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const ReviewListItem = styled.li` + display: flex; + flex-direction: column; + + min-width: ${({ theme }) => theme.writtenReviewLayoutSize.width}; + min-height: 20rem; + + border: 0.2rem solid ${({ theme }) => theme.colors.placeholder}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + + ${media.small} { + min-width: 30rem; + min-height: 18rem; + } +`; diff --git a/frontend/src/constants/amplitudeEventName.ts b/frontend/src/constants/amplitudeEventName.ts index c610d53b7..e50d8affd 100644 --- a/frontend/src/constants/amplitudeEventName.ts +++ b/frontend/src/constants/amplitudeEventName.ts @@ -24,6 +24,7 @@ export const PAGE_VISITED_EVENT_NAME: { [key in Exclude]: s detailedReview: '[page] 리뷰 상세 보기 페이지', reviewWriting: '[page] 리뷰 작성 페이지', reviewWritingComplete: '[page] 리뷰 작성 완료 페이지', + writtenReview: '[page] 작성한 리뷰 확인 페이지', }; export const REVIEW_WRITING_EVENT_NAME = { diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index 0826e9ff8..cf323e9fd 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -7,4 +7,5 @@ export const ROUTE = { detailedReview: 'user/detailed-review', reviewZone: 'user/review-zone', reviewCollection: 'user/review-collection', + writtenReview: 'user/written-review', }; diff --git a/frontend/src/hooks/useSearchParamAndQuery.ts b/frontend/src/hooks/useSearchParamAndQuery.ts index 349d9a6e5..898f1942f 100644 --- a/frontend/src/hooks/useSearchParamAndQuery.ts +++ b/frontend/src/hooks/useSearchParamAndQuery.ts @@ -1,13 +1,13 @@ import { useLocation, useParams } from 'react-router'; interface UseSearchParamAndQueryProps { - paramKey: string; + paramKey?: string; queryStringKey?: string; } /** * url에서 원하는 param, queryString의 값을 가져온다. * @param paramKey: 가져오고 싶은 param의 key - * @param queryStringKey: 가져오고 싶은 queryString의 key (옵셔널) + * @param queryStringKey: 가져오고 싶은 queryString의 key */ const useSearchParamAndQuery = ({ paramKey, queryStringKey }: UseSearchParamAndQueryProps) => { const location = useLocation(); diff --git a/frontend/src/pages/WrittenReviewPage/components/DetailedWrittenReview/index.tsx b/frontend/src/pages/WrittenReviewPage/components/DetailedWrittenReview/index.tsx new file mode 100644 index 000000000..8878c8c28 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/DetailedWrittenReview/index.tsx @@ -0,0 +1,27 @@ +import { NoSelectedReviewGuide } from '../index'; +import { PageContentLayout } from '../layouts'; + +import * as S from './styles'; + +interface DetailedWrittenReviewProps { + $isMobile: boolean; + selectedReviewId: number | null; +} + +// 라우팅으로 들어오는 경우 queryParam으로 reviewId를 가져올 수 있음 +// -> 그렇다면 라우터에서 이 컴포넌트를 별도의 props 없이 호출 가능, selectedReviewId는 optional 처리 +// but 일단은 props로 id를 무조건 받도록 구현해둔 상태 +const DetailedWrittenReview = ({ $isMobile, selectedReviewId }: DetailedWrittenReviewProps) => { + // 추후 이곳에서 직접 상세 리뷰 데이터 호출 + + // 라우팅으로 넘어온 경우 무조건 isMobile은 true + return ( + + + {selectedReviewId ?
{selectedReviewId} 선택함
: }
+
+
+ ); +}; + +export default DetailedWrittenReview; diff --git a/frontend/src/pages/WrittenReviewPage/components/DetailedWrittenReview/styles.ts b/frontend/src/pages/WrittenReviewPage/components/DetailedWrittenReview/styles.ts new file mode 100644 index 000000000..36800f68e --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/DetailedWrittenReview/styles.ts @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +interface DetailedWrittenReviewStyleProps { + $isMobile: boolean; +} + +export const DetailedWrittenReview = styled.div` + ${media.xSmall} { + ${({ $isMobile }) => + $isMobile + ? ` + display: block; + width: 100%; + ` + : ` + display: none; + `} + } +`; + +export const Outline = styled.div` + display: flex; + align-items: center; + + min-width: ${({ theme }) => theme.writtenReviewLayoutSize.width}; + min-height: ${({ theme }) => theme.writtenReviewLayoutSize.height}; + + border: 0.2rem solid ${({ theme }) => theme.colors.lightGray}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; +`; diff --git a/frontend/src/pages/WrittenReviewPage/components/NoSelectedReviewGuide/index.tsx b/frontend/src/pages/WrittenReviewPage/components/NoSelectedReviewGuide/index.tsx new file mode 100644 index 000000000..f9105028a --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/NoSelectedReviewGuide/index.tsx @@ -0,0 +1,14 @@ +import SlideArrowsIcon from '@/assets/slideArrows.svg'; + +import * as S from './styles'; + +const NoSelectedReviewGuide = () => { + return ( + + +

확인할 리뷰를 선택해주세요!

+
+ ); +}; + +export default NoSelectedReviewGuide; diff --git a/frontend/src/pages/WrittenReviewPage/components/NoSelectedReviewGuide/styles.ts b/frontend/src/pages/WrittenReviewPage/components/NoSelectedReviewGuide/styles.ts new file mode 100644 index 000000000..45985eaba --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/NoSelectedReviewGuide/styles.ts @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const NoSelectedReview = styled.section` + display: flex; + gap: 2rem; + align-items: center; + justify-content: center; + + margin: 0 auto; + + img { + height: 3rem; + + ${media.medium} { + height: 2.8rem; + margin-left: 2.5rem; + } + } + + p { + font-size: ${({ theme }) => theme.fontSize.mediumSmall}; + font-weight: bold; + color: ${({ theme }) => theme.colors.disabled}; + + ${media.medium} { + font-size: ${({ theme }) => theme.fontSize.basic}; + } + } +`; diff --git a/frontend/src/pages/WrittenReviewPage/components/WrittenReviewList/index.tsx b/frontend/src/pages/WrittenReviewPage/components/WrittenReviewList/index.tsx new file mode 100644 index 000000000..f8fa96677 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/WrittenReviewList/index.tsx @@ -0,0 +1,29 @@ +import ReviewListItem from '@/components/ReviewListItem'; + +import { PageContentLayout } from '../layouts'; + +import * as S from './styles'; + +interface WrittenReviewListProps { + handleClick: (reviewId: number) => void; +} + +const WrittenReviewList = ({ handleClick }: WrittenReviewListProps) => { + // 리뷰 리스트 받아오기 + const reviewIdList = [5, 1, 2, 3, 4]; + + return ( + + + {/** 추후 이벤트 위임 형식으로 변경 가능 */} + + {/** TODO: 작성한 리뷰 없을 때의 컴포넌트 추가*/} + {reviewIdList.map((reviewId) => ( + handleClick(reviewId)} /> + ))} + + + ); +}; + +export default WrittenReviewList; diff --git a/frontend/src/pages/WrittenReviewPage/components/WrittenReviewList/styles.ts b/frontend/src/pages/WrittenReviewPage/components/WrittenReviewList/styles.ts new file mode 100644 index 000000000..0aa1f819c --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/WrittenReviewList/styles.ts @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + + +export const WrittenReviewList = styled.ul` + overflow-x: hidden; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1.7rem; + + max-height: 68vh; + + ${media.xSmall} { + width: 100%; + } + + & > li { + margin-right: 0.5rem; + } +`; diff --git a/frontend/src/pages/WrittenReviewPage/components/index.tsx b/frontend/src/pages/WrittenReviewPage/components/index.tsx new file mode 100644 index 000000000..44f66add5 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/index.tsx @@ -0,0 +1,3 @@ +export { default as NoSelectedReviewGuide } from './NoSelectedReviewGuide'; +export { default as DetailedWrittenReview } from './DetailedWrittenReview'; +export { default as WrittenReviewList } from './WrittenReviewList'; diff --git a/frontend/src/pages/WrittenReviewPage/components/layouts/PageContentLayout/index.tsx b/frontend/src/pages/WrittenReviewPage/components/layouts/PageContentLayout/index.tsx new file mode 100644 index 000000000..ce61b04d9 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/layouts/PageContentLayout/index.tsx @@ -0,0 +1,18 @@ +import { EssentialPropsWithChildren } from '@/types'; + +import * as S from './styles'; + +interface WrittenReviewItemProps { + title: string; +} + +const PageContentLayout = ({ title, children }: EssentialPropsWithChildren) => { + return ( + + {title} + {children} + + ); +}; + +export default PageContentLayout; diff --git a/frontend/src/pages/WrittenReviewPage/components/layouts/PageContentLayout/styles.ts b/frontend/src/pages/WrittenReviewPage/components/layouts/PageContentLayout/styles.ts new file mode 100644 index 000000000..dabb41014 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/layouts/PageContentLayout/styles.ts @@ -0,0 +1,24 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const PageContentLayout = styled.article` + display: flex; + flex-direction: column; + height: 100%; + + ${media.xSmall} { + margin: 0 auto; + } +`; + +export const Title = styled.h2` + margin-top: 4.7rem; + margin-bottom: 2.4rem; + font-size: 1.8rem; + font-weight: bold; +`; + +export const Content = styled.section` + +`; diff --git a/frontend/src/pages/WrittenReviewPage/components/layouts/index.tsx b/frontend/src/pages/WrittenReviewPage/components/layouts/index.tsx new file mode 100644 index 000000000..fe53259cd --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/components/layouts/index.tsx @@ -0,0 +1 @@ +export { default as PageContentLayout } from './PageContentLayout'; diff --git a/frontend/src/pages/WrittenReviewPage/hooks/index.ts b/frontend/src/pages/WrittenReviewPage/hooks/index.ts new file mode 100644 index 000000000..f74b15f07 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/hooks/index.ts @@ -0,0 +1 @@ +export { default as useDeviceBreakpoints } from './useDeviceBreakpoints'; diff --git a/frontend/src/pages/WrittenReviewPage/hooks/useDeviceBreakpoints/index.ts b/frontend/src/pages/WrittenReviewPage/hooks/useDeviceBreakpoints/index.ts new file mode 100644 index 000000000..224cb0f4d --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/hooks/useDeviceBreakpoints/index.ts @@ -0,0 +1,48 @@ +import { useState, useLayoutEffect } from 'react'; + +import { breakpoint } from '@/styles/theme'; +import { Breakpoints } from '@/types/media'; +import { debounce } from '@/utils'; + +interface CurrentDevice { + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; +} + +const DEBOUNCE_TIME = 100; + +/** + 현재 미디어 쿼리 상태와 디바이스 종류(boolean)를 리턴하는 훅 + */ +const useDeviceBreakpoints = () => { + const [breakpointType, setBreakPointType] = useState(null); + const breakpointsArray = Object.entries(breakpoint); + + const getDeviceType = (breakpointType: Breakpoints | null): CurrentDevice => ({ + isMobile: breakpointType === 'xSmall' || breakpointType === 'xxSmall', + isTablet: breakpointType === 'small' || breakpointType === 'medium', + isDesktop: breakpointType === 'large', + }); + + const handleResize = debounce(() => { + const currentWidth = window.innerWidth; + const matchedBreakpoint = breakpointsArray.find(([, width]) => currentWidth <= width); + + setBreakPointType((matchedBreakpoint?.[0] as Breakpoints) ?? null); + }, DEBOUNCE_TIME); + + useLayoutEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + return { + breakpointType, + deviceType: getDeviceType(breakpointType), + }; +}; + +export default useDeviceBreakpoints; diff --git a/frontend/src/pages/WrittenReviewPage/index.tsx b/frontend/src/pages/WrittenReviewPage/index.tsx new file mode 100644 index 000000000..ca5cd6d86 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/index.tsx @@ -0,0 +1,51 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +import { ErrorSuspenseContainer, AuthAndServerErrorFallback } from '@/components'; +import { useSearchParamAndQuery } from '@/hooks'; + +import DetailedWrittenReview from './components/DetailedWrittenReview'; +import WrittenReviewList from './components/WrittenReviewList'; +import { useDeviceBreakpoints } from './hooks'; +import * as S from './styles'; + +const WrittenReviewPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { deviceType } = useDeviceBreakpoints(); + + const { queryString: reviewIdString } = useSearchParamAndQuery({ + queryStringKey: 'reviewId', + }); + + const selectedReviewId = reviewIdString ? Number(reviewIdString) : null; + + const handleReviewItemClick = (reviewId: number) => { + const params = new URLSearchParams(); + params.set('reviewId', reviewId.toString()); + + navigate(`${location.pathname}?${params.toString()}`); + }; + + const renderContent = () => { + if (deviceType.isMobile) { + // 모바일: queryString 없으면 목록, 있으면 상세보기 + return selectedReviewId ? ( + + ) : ( + + ); + } + + // 태블릿 ~ : 목록 + 상세보기 + return ( + + + + + ); + }; + + return {renderContent()}; +}; + +export default WrittenReviewPage; diff --git a/frontend/src/pages/WrittenReviewPage/styles.ts b/frontend/src/pages/WrittenReviewPage/styles.ts new file mode 100644 index 000000000..4de7d51b0 --- /dev/null +++ b/frontend/src/pages/WrittenReviewPage/styles.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const PageContainer = styled.div` + display: flex; + gap: 6rem; + justify-content: center; + + ${media.medium} { + gap: 4rem; + } + + ${media.small} { + margin: 0 2rem; + } +`; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 4dc586d19..868acc01b 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -7,3 +7,4 @@ export { default as ReviewWritingPage } from './ReviewWritingPage'; export { default as ReviewWritingCompletePage } from './ReviewWritingCompletePage'; export { default as ReviewZonePage } from './ReviewZonePage'; export { default as ReviewCollectionPage } from './ReviewCollectionPage'; +export { default as WrittenReviewPage } from './WrittenReviewPage'; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0fd64b227..71f93befc 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -10,6 +10,7 @@ const ReviewWritingPage = lazy(() => import('@/pages/ReviewWritingPage')); const ReviewZonePage = lazy(() => import('@/pages/ReviewZonePage')); const ReviewCollectionPage = lazy(() => import('@/pages/ReviewCollectionPage')); const LoadingPage = lazy(() => import('@/pages/LoadingPage')); +const WrittenReviewPage = lazy(() => import('@/pages/WrittenReviewPage')); import App from './App'; import { ErrorSuspenseContainer } from './components'; @@ -52,6 +53,7 @@ const router = createBrowserRouter([ ), }, { path: `${ROUTE.reviewCollection}/:${ROUTE_PARAM.reviewRequestCode}`, element: }, + { path: `${ROUTE.writtenReview}`, element: }, ], }, ]); diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index d3370a64e..e5028ad73 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -9,9 +9,11 @@ export const scrollbarWidth = { basic: '1.2rem', small: '0.5rem', }; + export const breadcrumbSize = { paddingLeft: '2rem', }; + export const confirmModalSize = { maxWidth: '90vw', padding: '3.2rem', @@ -23,6 +25,11 @@ export const contentModalSize = { smallPadding: '2rem', }; +export const writtenReviewLayoutSize = { + height: '68vh', + width: '35vw', +}; + export const componentHeight = { footer: '6rem', topbar: '7rem', @@ -102,6 +109,7 @@ const theme: Theme = { confirmModalSize, contentModalSize, breadcrumbSize, + writtenReviewLayoutSize, }; export default theme; diff --git a/frontend/src/types/emotion.ts b/frontend/src/types/emotion.ts index 61055fcd4..554083b9e 100644 --- a/frontend/src/types/emotion.ts +++ b/frontend/src/types/emotion.ts @@ -12,6 +12,7 @@ import { confirmModalSize, contentModalSize, breadcrumbSize, + writtenReviewLayoutSize, } from '../styles/theme'; export type Color = typeof colors; @@ -25,6 +26,7 @@ export type ComponentHeight = typeof componentHeight; export type ConfirmModalSize = typeof confirmModalSize; export type ContentModalSize = typeof contentModalSize; export type BreadcrumbSize = typeof breadcrumbSize; +export type WrittenReviewLayoutSize = typeof writtenReviewLayoutSize; type ThemeType = { fontSize: FontSize; @@ -39,6 +41,7 @@ type ThemeType = { confirmModalSize: ConfirmModalSize; contentModalSize: ContentModalSize; breadcrumbSize: BreadcrumbSize; + writtenReviewLayoutSize: WrittenReviewLayoutSize; }; declare module '@emotion/react' { diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts new file mode 100644 index 000000000..67b13e5f4 --- /dev/null +++ b/frontend/src/types/media.ts @@ -0,0 +1,3 @@ +import { breakpoint } from '@/styles/theme'; + +export type Breakpoints = keyof typeof breakpoint; diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts index ab61e7285..e4030cee4 100644 --- a/frontend/src/utils/media.ts +++ b/frontend/src/utils/media.ts @@ -1,10 +1,10 @@ import theme from '@/styles/theme'; +import { Breakpoints } from '@/types/media'; -const { breakpoint } = theme; - -export type Breakpoints = keyof typeof breakpoint; type Media = { [key in Breakpoints]: string }; +const { breakpoint } = theme; + const breakpointsKeyList = Object.keys(breakpoint) as Breakpoints[]; const media = breakpointsKeyList.reduce((prev, key, index) => {