diff --git a/src/Router.tsx b/src/Router.tsx
index 56a8035..acf1607 100644
--- a/src/Router.tsx
+++ b/src/Router.tsx
@@ -2,6 +2,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Settings from './components/Settings';
import DetailPage from './views/Detail/pages/DetailPage';
+import WriteReviewPage from './views/Detail/pages/WriteReviewPage';
import ErrorReportPage from './views/ErrorReport/pages/ErrorReportPage';
import LoginCallBack from './views/Login/components/LoginCallBack';
import SignUpPage from './views/Login/pages/SignUpPage';
@@ -22,6 +23,7 @@ const router = createBrowserRouter([
],
},
{ path: '/detail', element: },
+ { path: '/detail/review/write', element: },
{
path: '/search',
element: ,
diff --git a/src/assets/icon/icon-big-star-fill.svg b/src/assets/icon/icon-big-star-fill.svg
new file mode 100644
index 0000000..d822205
--- /dev/null
+++ b/src/assets/icon/icon-big-star-fill.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icon/icon-big-star.svg b/src/assets/icon/icon-big-star.svg
new file mode 100644
index 0000000..a74856b
--- /dev/null
+++ b/src/assets/icon/icon-big-star.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/icon/icon-camera.svg b/src/assets/icon/icon-camera.svg
new file mode 100644
index 0000000..4b2d3c6
--- /dev/null
+++ b/src/assets/icon/icon-camera.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/icon/icon-pencil-mono.svg b/src/assets/icon/icon-pencil-mono.svg
new file mode 100644
index 0000000..ace39a7
--- /dev/null
+++ b/src/assets/icon/icon-pencil-mono.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts
index dab9f60..bb23e7d 100644
--- a/src/assets/icon/index.ts
+++ b/src/assets/icon/index.ts
@@ -102,9 +102,13 @@ export { default as VideoGuideInActiveIcon } from './icon_videoguide_inactive.sv
export { default as WheelChairAcitveIcon } from './icon_wheelchair_active.svg?react';
export { default as WheelChairInAcitveIcon } from './icon_wheelchair_inactive.svg?react';
+export { default as EmptyPhotoIcon } from './icon_empty_photo.svg?react';
+export { default as PencilMonoIcon } from './icon-pencil-mono.svg?react';
+export { default as SmallStarIcon } from './small_star.svg?react';
+export { default as BigStarIcon } from './icon-big-star.svg?react';
+export { default as BigStarFillIcon } from './icon-big-star-fill.svg?react';
export { default as ArrowBackIconIosDownIcon } from './icon-arrow-back-ios-down.svg?react';
export { default as ArrowBackIconIosUpIcon } from './icon-arrow-back-ios-up.svg?react';
-export { default as EmptyPhotoIcon } from './icon_empty_photo.svg?react';
//Universal Filter
export { default as AudioGuideDefaultIcon } from './audioguide_default.svg?react';
@@ -129,6 +133,9 @@ export { default as GuideSystemDefaultIcon } from './guidesystem_default.svg?rea
export { default as GuideSystemSelectedIcon } from './guidesystem_selected.svg?react';
export { default as HelpDogDefaultIcon } from './helpdog_default.svg?react';
export { default as HelpDogSelectedIcon } from './helpdog_selected.svg?react';
+export { default as CameraIcon } from './icon-camera.svg?react';
+export { default as ToggleXFillIcon } from './toggle-fill-x.svg?react';
+export { default as NoReviewIcon } from './no_reveiw_image.svg?react';
export { default as LactationRoomDefaultIcon } from './lactationroom_default.svg?react';
export { default as LactationRoomSelectedIcon } from './lactationroom_selected.svg?react';
export { default as ParkingDefaultIcon } from './parking_default.svg?react';
diff --git a/src/assets/icon/no_reveiw_image.svg b/src/assets/icon/no_reveiw_image.svg
new file mode 100644
index 0000000..881ada5
--- /dev/null
+++ b/src/assets/icon/no_reveiw_image.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/icon/small_star.svg b/src/assets/icon/small_star.svg
new file mode 100644
index 0000000..68a0725
--- /dev/null
+++ b/src/assets/icon/small_star.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/assets/icon/toggle-fill-x.svg b/src/assets/icon/toggle-fill-x.svg
new file mode 100644
index 0000000..5de1b0e
--- /dev/null
+++ b/src/assets/icon/toggle-fill-x.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/components/ToastMessage.tsx b/src/components/ToastMessage.tsx
index 4197106..e0ad79b 100644
--- a/src/components/ToastMessage.tsx
+++ b/src/components/ToastMessage.tsx
@@ -22,9 +22,11 @@ const ToastMessage = (props: ToastMessageProps) => {
}, [setToast]);
return (
-
-
-
{children}
+
);
};
@@ -36,6 +38,16 @@ const fadeout = keyframes`
100% {opacity:0};
`;
+const rootContainer = css`
+ position: fixed;
+ left: 0;
+ bottom: 7.5rem;
+
+ width: 100%;
+
+ padding: 0 2rem;
+`;
+
const toastMessageContainer = () => css`
display: flex;
gap: 1.2rem;
@@ -43,7 +55,7 @@ const toastMessageContainer = () => css`
width: 100%;
padding: 1.7rem 0 1.7rem 2.4rem;
- border-radius: 1.6rem;
+ border-radius: 1rem;
background-color: ${COLORS.brand1};
diff --git a/src/types/api/review.ts b/src/types/api/review.ts
new file mode 100644
index 0000000..abcecba
--- /dev/null
+++ b/src/types/api/review.ts
@@ -0,0 +1,7 @@
+export interface ReviewResponse {
+ writer: string;
+ rate: number;
+ description: string;
+ convenience: string[];
+ imgUrl: string[];
+}
diff --git a/src/utils/storageHideGuide.ts b/src/utils/storageHideGuide.ts
index 0f7a68b..93e0f34 100644
--- a/src/utils/storageHideGuide.ts
+++ b/src/utils/storageHideGuide.ts
@@ -1,26 +1,22 @@
-import { STORAGE_KEY } from '@/views/Search/constants/localStorageKey';
-
-const key = STORAGE_KEY.hideSearchGuide;
-
-const getStorageHideGuide = () => {
+const getStorageHideGuide = (key: string) => {
const hideGuideTime = localStorage.getItem(key);
return hideGuideTime ? Number(hideGuideTime) : null;
};
// 24시간을 ms로
const EXPIRATION_PERIOD = 24 * 60 * 60 * 1000;
-export const setStorageHideGuide = () => {
+export const setStorageHideGuide = (key: string) => {
const date = new Date();
const expirationTime = date.getTime() + EXPIRATION_PERIOD;
localStorage.setItem(key, String(expirationTime));
};
-const removeStorageHideGuide = () => {
+const removeStorageHideGuide = (key: string) => {
localStorage.removeItem(key);
};
-export const isGuideShown = () => {
- const hideGuideTime = getStorageHideGuide();
+export const isGuideShown = (key: string) => {
+ const hideGuideTime = getStorageHideGuide(key);
const nowDate = new Date();
if (hideGuideTime) {
@@ -30,7 +26,7 @@ export const isGuideShown = () => {
}
// 만료 O
else {
- removeStorageHideGuide();
+ removeStorageHideGuide(key);
return true;
}
}
diff --git a/src/views/Detail/components/Review.tsx b/src/views/Detail/components/Review.tsx
new file mode 100644
index 0000000..106d389
--- /dev/null
+++ b/src/views/Detail/components/Review.tsx
@@ -0,0 +1,123 @@
+import { css } from '@emotion/react';
+import { useEffect, useState } from 'react';
+
+import { ReviewResponse } from '@/types/api/review';
+import { isGuideShown } from '@/utils/storageHideGuide';
+import {
+ createInitialFilterState,
+ getFilterList,
+} from '@/views/Search/constants/category';
+import { category } from '@/views/Search/types/category';
+
+import { STORAGE_KEY } from '../constants/localStorageKey';
+import CategoryBottomSheet from './review/CategoryBottomSheet';
+import Guide from './review/Guide';
+import NoReview from './review/NoReview';
+import ReviewCard from './review/ReviewCard';
+import SelectedCategory from './review/SelectedCategory';
+import TotalReview from './review/TotalReview';
+import TotalScore from './review/TotalScore';
+
+const REVIEW_DATA: ReviewResponse[] = [
+ {
+ writer: '왕이샹',
+ rate: 5,
+ description:
+ '앱에서 보았던 것과 같이 작품마다 점자표지판으로 설명이 있어 시각장애인도 불편하지 않게 관람이 가능했어요. 오디오 가이드 대여 서비스도 제공하니 필요하신 분들은 꼭 대여해서 쓰세요!!앱에서 보았던 것과 같이 작품마다 점자표지판으로 설명이 있어 시각장애인도 불편하지 않게 관람이 가능했어요. 오디오 가이드 대여 서비스도 제공하니 필요하신 분들은 꼭 대여해서 쓰세요!!',
+ convenience: ['주차장', '경사로'],
+ imgUrl: ['~~~', '~~~~'],
+ },
+ {
+ writer: '왕이샹',
+ rate: 3,
+ description:
+ '앱에서 보았던 것과 같이 작품마다 점자표지판으로 설명이 있어 시각장애인도 불편하지 않게 관람이 가능했어요. 오디오 가이드 대여 서비스도 제공하니 필요하신 분들은 꼭 대여해서 쓰세요!!',
+ convenience: ['주차장', '경사로'],
+ imgUrl: ['~~~', '~~~~'],
+ },
+ {
+ writer: '왕이샹',
+ rate: 2,
+ description:
+ '앱에서 보았던 것과 같이 작품마다 점자표지판으로 설명이 있어 시각장애인도 불편하지 않게 관람이 가능했어요. 오디오 가이드 대여 서비스도 제공하니 필요하신 분들은 꼭 대여해서 쓰세요!!',
+ convenience: ['주차장', '경사로'],
+ imgUrl: ['~~~', '~~~~'],
+ },
+];
+
+const Review = () => {
+ const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
+ const [showGuide, setShowGuide] = useState(() =>
+ isGuideShown(STORAGE_KEY.hideReviewFilterGuide),
+ );
+
+ const [filterState, setFilterState] = useState(() =>
+ createInitialFilterState('physical'),
+ );
+
+ const openBottomSheet = () => {
+ setIsBottomSheetOpen(true);
+ };
+ const closeBottomSheet = () => {
+ setIsBottomSheetOpen(false);
+ };
+
+ const handleSetShowGuide = (value: boolean) => {
+ setShowGuide(value);
+ document.body.style.overflow = '';
+ };
+
+ const handleFilterState = (category: category, facility: string) => {
+ const categoryFacilities = filterState[category];
+
+ setFilterState((prev) => ({
+ ...prev,
+ [category]: {
+ ...categoryFacilities,
+ [facility]: !categoryFacilities[facility],
+ },
+ }));
+ };
+
+ const selectedFilterList = getFilterList(filterState);
+
+ if (REVIEW_DATA.length === 0) return
;
+
+ useEffect(() => {
+ if (showGuide) document.body.style.overflow = 'hidden';
+ }, []);
+
+ return (
+ <>
+
+
+
+
+ {REVIEW_DATA.filter(({ convenience }) =>
+ convenience.some((c) => selectedFilterList.includes(c)),
+ ).map((item, idx) => {
+ return ;
+ })}
+
+
+ {showGuide &&
}
+ {isBottomSheetOpen && (
+
+ )}
+ >
+ );
+};
+
+export default Review;
+
+const reviewCardContainerCss = css`
+ padding: 2.3rem 2rem 0;
+`;
diff --git a/src/views/Detail/components/Tab.tsx b/src/views/Detail/components/Tab.tsx
index 9bb09b6..d541785 100644
--- a/src/views/Detail/components/Tab.tsx
+++ b/src/views/Detail/components/Tab.tsx
@@ -5,6 +5,7 @@ import { COLORS, FONTS } from '@/styles/constants';
import DetailInfo from './DetailInfo';
import Map from './Map';
import Photos from './Photos';
+import Review from './Review';
import Universal from './Universal';
const TAB_MENU = ['상세정보', '유니버설', '지도', '사진', '리뷰'];
@@ -37,6 +38,7 @@ function Tab(props: TabProps) {
{selectedTab === '사진' &&
}
{selectedTab === '지도' &&
}
{selectedTab === '유니버설' &&
}
+ {selectedTab === '리뷰' &&
}
);
}
diff --git a/src/views/Detail/components/review/CategoryBottomSheet.tsx b/src/views/Detail/components/review/CategoryBottomSheet.tsx
new file mode 100644
index 0000000..1c8b982
--- /dev/null
+++ b/src/views/Detail/components/review/CategoryBottomSheet.tsx
@@ -0,0 +1,46 @@
+import { css } from '@emotion/react';
+
+import BottomSheet from '@/components/BottomSheet';
+import { COLORS, FONTS } from '@/styles/constants';
+import { category, filterState } from '@/views/Search/types/category';
+
+import CategoryList from './CategoryList';
+
+interface CategoryBottomSheetProps {
+ closeBottomSheet: () => void;
+ filterState: filterState;
+ handleFilterState: (category: category, facility: string) => void;
+}
+
+const CategoryBottomSheet = (props: CategoryBottomSheetProps) => {
+ const { closeBottomSheet, ...filterProps } = props;
+
+ return (
+
+
+
+
+ );
+};
+
+export default CategoryBottomSheet;
+
+const titleCss = css`
+ margin-bottom: 1.2rem;
+
+ color: ${COLORS.brand1};
+ ${FONTS.H4};
+`;
diff --git a/src/views/Detail/components/review/CategoryList.tsx b/src/views/Detail/components/review/CategoryList.tsx
new file mode 100644
index 0000000..868768c
--- /dev/null
+++ b/src/views/Detail/components/review/CategoryList.tsx
@@ -0,0 +1,85 @@
+import { css } from '@emotion/react';
+
+import {
+ HEARING_FACILITIES,
+ INFANT_FACILITIES,
+ PHYSICAL_FACILITIES,
+ VISUAL_FACILITIES,
+} from '@/constants/facilities';
+import { COLORS, FONTS } from '@/styles/constants';
+import { filterState } from '@/views/Search/types/category';
+
+import { categoryButtonCss } from '../../styles/review';
+
+type category = 'physical' | 'visual' | 'hearing' | 'infant';
+interface Facility {
+ name: string;
+ active: JSX.Element;
+ inactive: JSX.Element;
+}
+
+export const MAP_CATEGORY_FACILITIES: Record<
+ category,
+ { categoryName: string; iconList: Facility[] }
+> = {
+ physical: { categoryName: '지체장애', iconList: PHYSICAL_FACILITIES },
+ visual: { categoryName: '시각장애', iconList: VISUAL_FACILITIES },
+ hearing: { categoryName: '청각장애', iconList: HEARING_FACILITIES },
+ infant: { categoryName: '영유아 가족', iconList: INFANT_FACILITIES },
+};
+
+interface CategoryListProps {
+ category: category;
+ filterState: filterState;
+ handleFilterState: (category: category, facility: string) => void;
+}
+
+const CategoryList = (props: CategoryListProps) => {
+ const { category, filterState, handleFilterState } = props;
+
+ const facilityState = filterState[category];
+ const handleOnClick = (facility: string) => {
+ handleFilterState(category, facility);
+ };
+
+ const categoryList = MAP_CATEGORY_FACILITIES[category].iconList.map(
+ ({ name }) => {
+ return (
+
+ );
+ },
+ );
+
+ return (
+
+
+ {MAP_CATEGORY_FACILITIES[category].categoryName}
+
+ {categoryList}
+
+ );
+};
+
+export default CategoryList;
+
+const containerCss = css`
+ margin: 2.4rem 0;
+`;
+
+const categoryNameCss = css`
+ margin-bottom: 0.8rem;
+
+ color: ${COLORS.brand1};
+ ${FONTS.Body2};
+`;
+
+const categoryContainerCss = css`
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+`;
diff --git a/src/views/Detail/components/review/Guide.tsx b/src/views/Detail/components/review/Guide.tsx
new file mode 100644
index 0000000..9dc01a7
--- /dev/null
+++ b/src/views/Detail/components/review/Guide.tsx
@@ -0,0 +1,100 @@
+import { css } from '@emotion/react';
+
+import { CheckFillIcon, XMonoIcon } from '@/assets/icon';
+import { COLORS, FONTS } from '@/styles/constants';
+import { setStorageHideGuide } from '@/utils/storageHideGuide';
+
+import { STORAGE_KEY } from '../../constants/localStorageKey';
+
+interface GuideProps {
+ handleSetShowGuide: (value: boolean) => void;
+}
+
+const Guide = (props: GuideProps) => {
+ const { handleSetShowGuide } = props;
+
+ const hideGuideForADay = () => {
+ setStorageHideGuide(STORAGE_KEY.hideReviewFilterGuide);
+ handleSetShowGuide(false);
+ };
+
+ return (
+
+
+
+
+ 내가 설정한 여행자 유형 필터는
+
더 빠르게 만나볼 수 있어요!
+
+
+
+
+
+
+ );
+};
+
+export default Guide;
+
+const containerCss = css`
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 999;
+
+ width: 100vw;
+ height: 100vh;
+`;
+
+const section1Css = css`
+ position: relative;
+ padding-top: 45rem;
+
+ height: 55rem;
+
+ background-color: rgb(82 82 82 / 72%);
+`;
+
+const section2Css = css`
+ height: 100%;
+ margin-top: 6rem;
+ padding-top: 1.25rem;
+
+ background-color: rgb(82 82 82 / 72%);
+`;
+
+const buttonCss = css`
+ position: absolute;
+ top: 2.5rem;
+ right: 2.4rem;
+
+ color: ${COLORS.white};
+`;
+
+const textCss = css`
+ padding-top: 2rem;
+ margin-left: 2rem;
+
+ color: ${COLORS.white};
+
+ ${FONTS.H5};
+`;
+
+const buttonTextCss = css`
+ display: flex;
+ gap: 0.6rem;
+ align-items: center;
+
+ margin-left: 2rem;
+
+ color: ${COLORS.white};
+
+ ${FONTS.Body4};
+`;
diff --git a/src/views/Detail/components/review/NoReview.tsx b/src/views/Detail/components/review/NoReview.tsx
new file mode 100644
index 0000000..ffa3cff
--- /dev/null
+++ b/src/views/Detail/components/review/NoReview.tsx
@@ -0,0 +1,58 @@
+import { css } from '@emotion/react';
+import { Link } from 'react-router-dom';
+
+import { NoReviewIcon } from '@/assets/icon';
+import { COLORS, FONTS } from '@/styles/constants';
+
+const NoReview = () => {
+ return (
+
+
+
아직 리뷰가 없어요
+
+ 해당 장소에 대해 알고 계시나요?
+
새롭게 리뷰를 작성해 주세요!
+
+
+
+ 리뷰 작성하기
+
+
+ );
+};
+
+export default NoReview;
+
+const containerCss = css`
+ display: flex;
+ flex-direction: column;
+
+ align-items: center;
+
+ margin-top: 2.4rem;
+`;
+
+const titleCss = css`
+ margin: 2rem 0 0.8rem;
+ color: ${COLORS.gray9};
+ ${FONTS.Body2}
+`;
+
+const descriptionCss = css`
+ text-align: center;
+
+ color: ${COLORS.brand1};
+ ${FONTS.Small1}
+`;
+
+const buttonCss = css`
+ padding: 0.8rem 1.6rem;
+ margin: 2.4rem 0;
+
+ border-radius: 1rem;
+
+ color: ${COLORS.white};
+ background-color: ${COLORS.brand1};
+
+ ${FONTS.Body3};
+`;
diff --git a/src/views/Detail/components/review/ReviewCard.tsx b/src/views/Detail/components/review/ReviewCard.tsx
new file mode 100644
index 0000000..0a729c9
--- /dev/null
+++ b/src/views/Detail/components/review/ReviewCard.tsx
@@ -0,0 +1,177 @@
+import { css } from '@emotion/react';
+import { useEffect, useRef, useState } from 'react';
+
+import { SmallStarIcon } from '@/assets/icon';
+import { COLORS, FONTS } from '@/styles/constants';
+
+interface ReviewCardProps {
+ writer: string;
+ rate: number;
+ description: string;
+ convenience: string[];
+ imgUrl: string[];
+}
+
+const ReviewCard = (props: ReviewCardProps) => {
+ const { writer, rate, description, convenience, imgUrl } = props;
+
+ const [showAll, setShowAll] = useState(false);
+ const [isMoreButton, setIsMoreButton] = useState(false);
+
+ const descriptionRef = useRef(null);
+
+ // 5.25rem
+ // 63
+ useEffect(() => {
+ if (
+ descriptionRef.current?.scrollHeight >
+ descriptionRef.current?.offsetHeight
+ ) {
+ setIsMoreButton(true);
+ }
+ }, []);
+
+ return (
+
+
+
+
{writer}
+
+ {rate}
+
+
+
{convenience.join(' | ')}
+
+
+ {description}
+ {isMoreButton && !showAll && (
+
+
...
{' '}
+
+
+ )}
+
+
+
+
+ {imgUrl.map((imgUrl) => (
+

+ ))}
+
+
+ 2024.07.28
+
+ );
+};
+
+export default ReviewCard;
+
+const containerCss = css`
+ padding-bottom: 2rem;
+ border-radius: 2rem;
+ border; 1px solid rgba(245, 245, 245, 0.5);
+ box-shadow: 0px 4px 20px 0px rgba(0, 0, 0, 0.04);
+`;
+
+const contentContainerCss = css`
+ padding: 2rem;
+`;
+
+const authorCss = css`
+ color: ${COLORS.brand1};
+ ${FONTS.Body1};
+`;
+
+const startContainerCss = css`
+ display: flex;
+ align-items: center;
+
+ gap: 0.4rem;
+
+ color: ${COLORS.brand1};
+ ${FONTS.Small1};
+`;
+
+const headerCss = css`
+ display: flex;
+ justify-content: space-between;
+`;
+
+const categoryCss = css`
+ margin-top: 0.4rem;
+
+ color: ${COLORS.brand1};
+ ${FONTS.Body2};
+`;
+
+const contentCss = (showAll: boolean) => css`
+ position: relative;
+
+ display: -webkit-box;
+ word-wrap: break-word;
+ -webkit-line-clamp: ${showAll ? 'none' : 4};
+ -webkit-box-orient: vertical;
+ text-overflow: ellipsis;
+ overflow: hidden;
+
+ word-break: keep-all;
+ margin-top: 1.6rem;
+
+ color: ${COLORS.brand1};
+ ${FONTS.Body5};
+`;
+
+const imgContainerCss = css`
+ display: flex;
+ gap: 0.5rem;
+
+ width: 100%;
+ padding: 0 2rem;
+ overflow: auto;
+
+ & > img {
+ width: 12.3rem;
+ height: 12.3rem;
+
+ border-radius: 1.1rem;
+ }
+`;
+
+const dateCss = css`
+ margin: 1.6rem 0 0 2rem;
+
+ font-family: 'Apple SD Gothic Neo', sans-serif;
+ font-style: normal;
+ font-size: 1.4rem;
+ font-weight: 400;
+ line-height: 140%;
+
+ color: ${COLORS.gray4};
+`;
+
+const moreContentCss = css`
+ display: flex;
+
+ position: absolute;
+ bottom: 0;
+ right: 0;
+
+ padding-left: 20px;
+
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 255, 255, 1) 18%
+ );
+
+ color: ${COLORS.brand1};
+ ${FONTS.Body5};
+`;
+
+const moreContentButtonCss = css`
+ text-decoration: underline;
+`;
diff --git a/src/views/Detail/components/review/SelectedCategory.tsx b/src/views/Detail/components/review/SelectedCategory.tsx
new file mode 100644
index 0000000..8be541e
--- /dev/null
+++ b/src/views/Detail/components/review/SelectedCategory.tsx
@@ -0,0 +1,102 @@
+import { css } from '@emotion/react';
+
+import { COLORS, FONTS } from '@/styles/constants';
+import { category, filterState } from '@/views/Search/types/category';
+
+import { categoryButtonCss } from '../../styles/review';
+import { MAP_CATEGORY_FACILITIES } from './CategoryList';
+
+interface SelectedCategoryProps {
+ openBottomSheet: () => void;
+ filterState: filterState;
+ handleFilterState: (category: category, facility: string) => void;
+}
+
+const SelectedCategory = (props: SelectedCategoryProps) => {
+ const { openBottomSheet, filterState, handleFilterState } = props;
+
+ const renderSelectedCategoryList = () => {
+ const categoryList = Object.entries(filterState).filter(
+ ([, objectValue]) => {
+ return Object.values(objectValue).some((value) => value);
+ },
+ ) as [category, Record][];
+
+ return categoryList.map(([category, facilityList]) => {
+ const facilityState = filterState[category];
+
+ return (
+
+
+ {MAP_CATEGORY_FACILITIES[category].categoryName}
+
+
+ {Object.keys(facilityList).map((facility) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ });
+ };
+
+ return (
+
+ {renderSelectedCategoryList()}
+
+
+ );
+};
+
+export default SelectedCategory;
+
+const containerCss = css`
+ padding-top: 0.94rem;
+`;
+
+const selectedCategoryContainerCss = css`
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+
+ padding: 0 0 0.94rem 1.9rem;
+
+ overflow: auto;
+`;
+
+const buttonCss = css`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ width: 100%;
+
+ padding: 1.2rem 0;
+
+ color: ${COLORS.brand1};
+ background-color: ${COLORS.gray0};
+ ${FONTS.Body1}
+`;
+
+const categoryNameCss = css`
+ min-width: 7.4rem;
+
+ color: ${COLORS.brand1};
+ ${FONTS.Body2};
+`;
+
+const facilitiesContainerCss = css`
+ display: flex;
+ gap: 1rem;
+
+ overflow: auto;
+`;
diff --git a/src/views/Detail/components/review/TotalReview.tsx b/src/views/Detail/components/review/TotalReview.tsx
new file mode 100644
index 0000000..bf99768
--- /dev/null
+++ b/src/views/Detail/components/review/TotalReview.tsx
@@ -0,0 +1,47 @@
+import { css } from '@emotion/react';
+import { Link } from 'react-router-dom';
+
+import { PencilMonoIcon } from '@/assets/icon';
+import { COLORS, FONTS } from '@/styles/constants';
+
+interface TotalReviewProps {
+ reviewCount: number;
+}
+
+const TotalReview = (props: TotalReviewProps) => {
+ const { reviewCount } = props;
+
+ return (
+
+
+ 리뷰
+ {reviewCount}
+
+
+
+
+
+ );
+};
+
+export default TotalReview;
+
+const containerCss = css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ width: 100%;
+ height: 5.6rem;
+ padding: 0 1.9rem;
+ border-top: 1px solid ${COLORS.gray0};
+ border-bottom: 1px solid ${COLORS.gray2};
+
+ ${FONTS.H5};
+`;
+
+const reviewCountCss = css`
+ display: flex;
+ gap: 1.2rem;
+ justify-content: flex-start;
+`;
diff --git a/src/views/Detail/components/review/TotalScore.tsx b/src/views/Detail/components/review/TotalScore.tsx
new file mode 100644
index 0000000..e6f47aa
--- /dev/null
+++ b/src/views/Detail/components/review/TotalScore.tsx
@@ -0,0 +1,63 @@
+import { css } from '@emotion/react';
+
+import { StarIcon } from '@/assets/icon';
+import { COLORS, FONTS } from '@/styles/constants';
+import { ReviewResponse } from '@/types/api/review';
+
+interface TotalScoreProps {
+ reviewData: ReviewResponse[];
+}
+
+const TotalScore = (props: TotalScoreProps) => {
+ const { reviewData } = props;
+
+ const averageScore =
+ reviewData.reduce((acc, cur) => acc + cur.rate, 0) / reviewData.length;
+
+ const renderStar = () => {
+ const starEl = [];
+ for (let i = 0; i < Math.floor(averageScore); i++) {
+ starEl.push();
+ }
+ return starEl;
+ };
+ return (
+
+
{renderStar()}
+
+ {Math.round(averageScore * 10) / 10} / 5.0
+
+
+ );
+};
+
+export default TotalScore;
+
+const containerCss = css`
+ display: flex;
+ gap: 3.2rem;
+
+ margin: 1.5rem 0 1.5rem 2rem;
+`;
+
+const starContainerCss = css`
+ display: flex;
+ gap: 0.3rem;
+ align-items: center;
+
+ & > svg {
+ width: 2.2rem;
+ height: 2.2rem;
+ }
+`;
+
+const scoreContainerCss = css`
+ display: flex;
+ gap: 1.2rem;
+
+ ${FONTS.H4};
+
+ & > span {
+ color: ${COLORS.gray3};
+ }
+`;
diff --git a/src/views/Detail/components/review/write/CategoryBottomSheet.tsx b/src/views/Detail/components/review/write/CategoryBottomSheet.tsx
new file mode 100644
index 0000000..c1ac3c7
--- /dev/null
+++ b/src/views/Detail/components/review/write/CategoryBottomSheet.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+const CategoryBottomSheet = () => {
+ return <>>;
+};
+
+export default CategoryBottomSheet;
diff --git a/src/views/Detail/components/review/write/Description.tsx b/src/views/Detail/components/review/write/Description.tsx
new file mode 100644
index 0000000..fc229ce
--- /dev/null
+++ b/src/views/Detail/components/review/write/Description.tsx
@@ -0,0 +1,21 @@
+import { css } from '@emotion/react';
+import React, { ReactNode } from 'react';
+
+import { COLORS, FONTS } from '@/styles/constants';
+
+interface DescriptionProps {
+ children: ReactNode;
+}
+
+const Description = (props: DescriptionProps) => {
+ const { children } = props;
+
+ return {children}
;
+};
+
+export default Description;
+
+const descriptionCss = css`
+ color: ${COLORS.gray6};
+ ${FONTS.Small1}
+`;
diff --git a/src/views/Detail/components/review/write/ExperienceInput.tsx b/src/views/Detail/components/review/write/ExperienceInput.tsx
new file mode 100644
index 0000000..e72bfe5
--- /dev/null
+++ b/src/views/Detail/components/review/write/ExperienceInput.tsx
@@ -0,0 +1,51 @@
+import { css } from '@emotion/react';
+
+import { COLORS, FONTS } from '@/styles/constants';
+
+import Description from './Description';
+import Question from './Question';
+
+interface ExperienceInput {
+ experience: string;
+ handleExperience: (value: string) => void;
+}
+
+const ExperienceInput = (props: ExperienceInput) => {
+ const { experience, handleExperience } = props;
+
+ return (
+
+ 어떤 경험을 하셨나요? *
+ 여행지에서 느낀 점을 자세하게 입력해주세요
+
+ );
+};
+
+export default ExperienceInput;
+
+const textAreaCss = css`
+ width: 100%;
+ height: 12rem;
+
+ padding: 1.6rem;
+ margin-top: 1.6rem;
+
+ border-radius: 1rem;
+ border: 1px solid ${COLORS.gray3};
+
+ &:focus {
+ outline: none;
+ border: 1px solid ${COLORS.brand1};
+ }
+
+ &::placeholder {
+ color: ${COLORS.gray4};
+ }
+ ${FONTS.Body2};
+`;
diff --git a/src/views/Detail/components/review/write/Facilities.tsx b/src/views/Detail/components/review/write/Facilities.tsx
new file mode 100644
index 0000000..7fd1f34
--- /dev/null
+++ b/src/views/Detail/components/review/write/Facilities.tsx
@@ -0,0 +1,71 @@
+import { css } from '@emotion/react';
+
+import { COLORS, FONTS } from '@/styles/constants';
+import { filterState } from '@/views/Search/types/category';
+
+import { categoryButtonCss as categoryCss } from '../../../styles/review';
+import Description from './Description';
+import Question from './Question';
+
+interface FacilitiesProps {
+ openBottomSheet: () => void;
+ filterState: filterState;
+}
+
+const Facilities = (props: FacilitiesProps) => {
+ const { openBottomSheet, filterState } = props;
+
+ const renderSelectedCategoryList = () => {
+ const categoryList = Object.values(filterState)
+ .flatMap((object) =>
+ Object.entries(object)
+ .filter(([, value]) => value)
+ .map(([key]) => key),
+ )
+ .map((name) => (
+
+ {name}
+
+ ));
+
+ return (
+ categoryList.length > 0 && (
+
+ )
+ );
+ };
+
+ return (
+
+ 어떤 편의시설이 있었나요?
+ 남겨주신 정보는 다른 사용자에게 큰 도움이 돼요
+ {renderSelectedCategoryList()}
+
+
+ );
+};
+
+export default Facilities;
+
+const categoryButtonCss = css`
+ height: 5.6rem;
+ padding: 1.2rem 2.4rem;
+ margin-top: 1.6rem;
+
+ border: 1px solid ${COLORS.gray3};
+ border-radius: 1.2rem;
+ width: 100%;
+
+ color: ${COLORS.gray9};
+ ${FONTS.Body2};
+`;
+
+const categoryContainerCss = css`
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+
+ margin-top: 1.6rem;
+`;
diff --git a/src/views/Detail/components/review/write/ImageInput.tsx b/src/views/Detail/components/review/write/ImageInput.tsx
new file mode 100644
index 0000000..33929ff
--- /dev/null
+++ b/src/views/Detail/components/review/write/ImageInput.tsx
@@ -0,0 +1,100 @@
+import { css } from '@emotion/react';
+
+import { CameraIcon, ToggleXFillIcon } from '@/assets/icon';
+import { COLORS } from '@/styles/constants';
+
+import Description from './Description';
+import Question from './Question';
+
+interface ImageInputProps {
+ imgList: string[];
+ addImg: (imgUrl: string) => void;
+ removeImg: (imgUrl: string) => void;
+}
+
+const ImageInput = (props: ImageInputProps) => {
+ const { imgList, addImg, removeImg } = props;
+
+ const handleOnChange = () => {
+ addImg('asdf');
+ };
+
+ return (
+
+
사진으로 생생한 경험을 공유해주세요!
+
최대 10장까지 사진을 올릴 수 있어요
+
+ {imgList.map((imgUrl) => (
+
+

+
+
+ ))}
+ {imgList.length < 10 && (
+
+ )}
+
+
+ );
+};
+
+export default ImageInput;
+
+const imageSquareLabelCss = css`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+
+ aspect-ratio: 1;
+
+ border-radius: 1.2rem;
+
+ background-color: ${COLORS.gray1};
+ color: ${COLORS.gray6};
+
+ font-family: 'Apple SD Gothic Neo', sans-serif;
+ font-style: normal;
+ font-size: 1.3rem;
+ font-weight: 400;
+ line-height: 140%;
+`;
+
+const imgContainerCss = css`
+ display: grid;
+ gap: 1.2rem;
+ grid-template-columns: repeat(3, 1fr);
+
+ margin-top: 1.6rem;
+
+ border-radius: 1.2rem;
+`;
+
+const imgBoxContainerCss = css`
+ position: relative;
+ aspect-ratio: 1;
+
+ & > button {
+ position: absolute;
+ top: 0.8rem;
+ right: 0.8rem;
+ }
+`;
+
+const imageButtonCss = css`
+ width: 0;
+ height: 0;
+ padding: 0;
+ border: 0;
+`;
+
+const imgCss = css`
+ border-radius: 1.2rem;
+`;
diff --git a/src/views/Detail/components/review/write/Question.tsx b/src/views/Detail/components/review/write/Question.tsx
new file mode 100644
index 0000000..e24f84e
--- /dev/null
+++ b/src/views/Detail/components/review/write/Question.tsx
@@ -0,0 +1,21 @@
+import { css } from '@emotion/react';
+import { ReactNode } from 'react';
+
+import { COLORS, FONTS } from '@/styles/constants';
+
+interface QuestionProps {
+ children: ReactNode;
+}
+
+const Question = (props: QuestionProps) => {
+ const { children } = props;
+
+ return {children}
;
+};
+
+export default Question;
+
+const questionCss = css`
+ color: ${COLORS.gray9};
+ ${FONTS.H5};
+`;
diff --git a/src/views/Detail/components/review/write/ScoreSection.tsx b/src/views/Detail/components/review/write/ScoreSection.tsx
new file mode 100644
index 0000000..3d1811e
--- /dev/null
+++ b/src/views/Detail/components/review/write/ScoreSection.tsx
@@ -0,0 +1,45 @@
+import { css } from '@emotion/react';
+
+import { BigStarFillIcon, BigStarIcon } from '@/assets/icon';
+
+import Description from './Description';
+import Question from './Question';
+
+interface ScoreSectionProps {
+ score: number;
+ handleScore: (score: number) => void;
+}
+
+const ScoreSection = (props: ScoreSectionProps) => {
+ const { score, handleScore } = props;
+
+ const starList = Array.from({ length: 5 }, (_, idx) => {
+ return idx < score ? (
+
+ ) : (
+
+ );
+ });
+
+ return (
+
+
여행지는 어떠셨나요? *
+
별점을 남겨주세요
+
+
{starList}
+
+ );
+};
+
+export default ScoreSection;
+
+const starContainerCss = css`
+ display: flex;
+ gap: 0.4rem;
+
+ margin-top: 1.6rem;
+`;
diff --git a/src/views/Detail/constants/localStorageKey.ts b/src/views/Detail/constants/localStorageKey.ts
new file mode 100644
index 0000000..35c5f71
--- /dev/null
+++ b/src/views/Detail/constants/localStorageKey.ts
@@ -0,0 +1,3 @@
+export const STORAGE_KEY = {
+ hideReviewFilterGuide: 'hide-review-filter-guide',
+};
diff --git a/src/views/Detail/pages/WriteReviewPage.tsx b/src/views/Detail/pages/WriteReviewPage.tsx
new file mode 100644
index 0000000..b2779da
--- /dev/null
+++ b/src/views/Detail/pages/WriteReviewPage.tsx
@@ -0,0 +1,163 @@
+import { css } from '@emotion/react';
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { ChevronLeftIcon } from '@/assets/icon';
+import ToastMessage from '@/components/ToastMessage';
+import { COLORS, FONTS } from '@/styles/constants';
+import {
+ getFilterList,
+ INITIAL_FILTER_STATE,
+} from '@/views/Search/constants/category';
+import { category } from '@/views/Search/types/category';
+
+import CategoryBottomSheet from '../components/review/CategoryBottomSheet';
+import ExperienceInput from '../components/review/write/ExperienceInput';
+import Facilities from '../components/review/write/Facilities';
+import ImageInput from '../components/review/write/ImageInput';
+import ScoreSection from '../components/review/write/ScoreSection';
+
+const WriteReviewPage = () => {
+ const navigate = useNavigate();
+
+ const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
+ const [toast, setToast] = useState(false);
+
+ const [score, setScore] = useState(0);
+ const [experience, setExperience] = useState('');
+ const [filterState, setFilterState] = useState(INITIAL_FILTER_STATE);
+ const [imgList, setImgList] = useState([]);
+
+ const handleScore = (score: number) => {
+ setScore(score);
+ };
+
+ const openBottomSheet = () => {
+ setIsBottomSheetOpen(true);
+ };
+
+ const closeBottomSheet = () => {
+ setIsBottomSheetOpen(false);
+ };
+
+ const handleExperience = (value: string) => {
+ setExperience(value);
+ };
+
+ const handleFilterState = (category: category, facility: string) => {
+ const categoryFacilities = filterState[category];
+
+ setFilterState((prev) => ({
+ ...prev,
+ [category]: {
+ ...categoryFacilities,
+ [facility]: !categoryFacilities[facility],
+ },
+ }));
+ };
+
+ const addImg = (imgUrl: string) => {
+ setImgList((prev) => [...prev, imgUrl]);
+ };
+
+ const removeImg = (imgUrl: string) => {
+ const filteredImgList = imgList.filter((item) => item !== imgUrl);
+ setImgList(filteredImgList);
+ };
+
+ const handleOnClick = () => {
+ console.log({
+ rate: score,
+ description: experience,
+ convenience: getFilterList(filterState),
+ imgUrl: imgList,
+ });
+ setToast(true);
+ };
+
+ return (
+ <>
+
+
+
+ 리뷰 작성
+
+
+
+
+
+
+
+
+
+
+ {toast && (
+
+ 리뷰가 저장되었습니다.
+
+ )}
+ {isBottomSheetOpen && (
+
+ )}
+
+ >
+ );
+};
+
+export default WriteReviewPage;
+
+const containerCss = css`
+ padding: 0 2rem;
+`;
+
+const headerCss = css`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+
+ width: 100%;
+ padding: 1.2rem 0;
+ margin-bottom: 2rem;
+
+ color: ${COLORS.gray9};
+
+ ${FONTS.Body2};
+
+ & > button {
+ position: absolute;
+ left: 0;
+ }
+`;
+
+const writeContainerCss = css`
+ display: flex;
+ gap: 2.8rem;
+ flex-direction: column;
+`;
+
+const submitCss = css`
+ width: 100%;
+ height: 5.6rem;
+ margin: 7.2rem 0 0.5rem;
+ border-radius: 1.2rem;
+
+ background-color: ${COLORS.brand1};
+
+ color: ${COLORS.white};
+ ${FONTS.Body2};
+`;
diff --git a/src/views/Detail/styles/review.ts b/src/views/Detail/styles/review.ts
new file mode 100644
index 0000000..26ccc73
--- /dev/null
+++ b/src/views/Detail/styles/review.ts
@@ -0,0 +1,15 @@
+import { css } from '@emotion/react';
+
+import { COLORS, FONTS } from '@/styles/constants';
+
+export const categoryButtonCss = (isSelected: boolean) => css`
+ border-radius: 1.7rem;
+ border: 1px solid #d6d6d6;
+ padding: 0.7rem 1.5rem;
+ color: ${isSelected ? COLORS.white : '#616671'};
+ ${FONTS.Body3};
+
+ min-width: fit-content;
+
+ background-color: ${isSelected && COLORS.brand1};
+`;
diff --git a/src/views/Search/components/Result/Guide.tsx b/src/views/Search/components/Result/Guide.tsx
index f62ecf4..86017f7 100644
--- a/src/views/Search/components/Result/Guide.tsx
+++ b/src/views/Search/components/Result/Guide.tsx
@@ -4,6 +4,8 @@ import { CheckFillIcon, XMonoIcon } from '@/assets/icon';
import { COLORS, FONTS } from '@/styles/constants';
import { setStorageHideGuide } from '@/utils/storageHideGuide';
+import { STORAGE_KEY } from '../../constants/localStorageKey';
+
interface GuideProps {
handleSetShowGuide: (value: boolean) => void;
}
@@ -12,7 +14,7 @@ const Guide = (props: GuideProps) => {
const { handleSetShowGuide } = props;
const hideGuideForADay = () => {
- setStorageHideGuide();
+ setStorageHideGuide(STORAGE_KEY.hideSearchGuide);
handleSetShowGuide(false);
};
diff --git a/src/views/Search/constants/category.ts b/src/views/Search/constants/category.ts
index e937fbd..740d43c 100644
--- a/src/views/Search/constants/category.ts
+++ b/src/views/Search/constants/category.ts
@@ -18,7 +18,7 @@ export const MAP_CATEGORY_FACILITIES: Record<
infant: { categoryName: '영유아 가족', iconList: INFANT_FACILITIES },
};
-const INITIAL_FILTER_STATE: filterState = {
+export const INITIAL_FILTER_STATE: filterState = {
physical: {
주차장: false,
접근로: false,
@@ -51,6 +51,19 @@ const INITIAL_FILTER_STATE: filterState = {
},
};
-export const createInitialFilterState = () => {
+export const getFilterList = (filterState: filterState) => {
+ return Object.values(filterState).flatMap((obj) =>
+ Object.entries(obj)
+ .filter(([, value]) => value)
+ .map(([key]) => key),
+ );
+};
+
+export const createInitialFilterState = (initialCategory: category) => {
+ const filterState = INITIAL_FILTER_STATE;
+ Object.keys(INITIAL_FILTER_STATE[initialCategory]).forEach((key) => {
+ filterState[initialCategory][key] = true;
+ });
+
return INITIAL_FILTER_STATE;
};
diff --git a/src/views/Search/pages/SearchResultPage.tsx b/src/views/Search/pages/SearchResultPage.tsx
index 1a80f2c..6003ef2 100644
--- a/src/views/Search/pages/SearchResultPage.tsx
+++ b/src/views/Search/pages/SearchResultPage.tsx
@@ -18,6 +18,7 @@ import {
createInitialFilterState,
MAP_CATEGORY_FACILITIES,
} from '../constants/category';
+import { STORAGE_KEY } from '../constants/localStorageKey';
import { category } from '../types/category';
const SearchResultPage = () => {
@@ -25,12 +26,14 @@ const SearchResultPage = () => {
const { pathname } = useLocation();
const [filterState, setFilterState] = useState(() =>
- createInitialFilterState(),
+ createInitialFilterState('physical'),
);
// modal, bottom sheet state
const [placeList, setPlaceList] = useState([]);
- const [showGuide, setShowGuide] = useState(() => isGuideShown());
+ const [showGuide, setShowGuide] = useState(() =>
+ isGuideShown(STORAGE_KEY.hideSearchGuide),
+ );
const [isFilterOpen, setIsFilterOpen] = useState(false);
// state handling func