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) => {