From 47db888342f25ad038d10a754c8a8feb33a522bf Mon Sep 17 00:00:00 2001 From: SeoHyun Kim Date: Tue, 24 Sep 2024 21:17:11 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EB=A6=AC=EB=B7=B0=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20/=20=EB=A6=AC=EB=B7=B0=20=EC=9E=91=EC=84=B1=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=8B=B1=20(#5?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리뷰 탭 상단 부분 퍼블리싱 * feat: 리뷰 목록 퍼블리싱 * feat: 리뷰 바텀시트 퍼블리싱 * feat: 기본 뷰 퍼블리싱 * feat: 편의시설 선택하기 바텀시트 * feat: 편의시설 선택 * feat: textarea state 추가 * feat: img list state 추가 * feat: 바뀐 디자인 적용 * feat: 편의시설 css margin 조절 * feat: textarea focus border * chore: 임시 커밋 * feat: 리뷰 없을 때 화면 퍼블리싱 * feat: 리뷰 없을 때 화면 조건분기 * feat: 평균 rate * feat: 리뷰 개수 * feat: 필터 버튼 재구현 * feat: 리뷰 가이드 구현 * feat: 리뷰 말줄임표 더보기 구현 * feat: img length 9 개까지만 첨부 버튼 띄우기 * feat: 리뷰 등록 토스트 * fix: rem 값 수정 * feat: 리뷰 필터링 구현 * feat: post 객체 미리 만들기 * feat: default 카테고리 로직 구현 * feat: 리뷰 필터 가이드 body overflow hidden 핸들링 * fix: 리뷰 작성 페이지 초기값 선택 없도록 수정 * fix: 토스트 초기값 수정 --- src/Router.tsx | 2 + src/assets/icon/icon-big-star-fill.svg | 3 + src/assets/icon/icon-big-star.svg | 5 + src/assets/icon/icon-camera.svg | 6 + src/assets/icon/icon-pencil-mono.svg | 3 + src/assets/icon/index.ts | 9 +- src/assets/icon/no_reveiw_image.svg | 18 ++ src/assets/icon/small_star.svg | 10 + src/assets/icon/toggle-fill-x.svg | 9 + src/components/ToastMessage.tsx | 20 +- src/types/api/review.ts | 7 + src/utils/storageHideGuide.ts | 16 +- src/views/Detail/components/Review.tsx | 123 ++++++++++++ src/views/Detail/components/Tab.tsx | 2 + .../components/review/CategoryBottomSheet.tsx | 46 +++++ .../Detail/components/review/CategoryList.tsx | 85 +++++++++ src/views/Detail/components/review/Guide.tsx | 100 ++++++++++ .../Detail/components/review/NoReview.tsx | 58 ++++++ .../Detail/components/review/ReviewCard.tsx | 177 ++++++++++++++++++ .../components/review/SelectedCategory.tsx | 102 ++++++++++ .../Detail/components/review/TotalReview.tsx | 47 +++++ .../Detail/components/review/TotalScore.tsx | 63 +++++++ .../review/write/CategoryBottomSheet.tsx | 7 + .../components/review/write/Description.tsx | 21 +++ .../review/write/ExperienceInput.tsx | 51 +++++ .../components/review/write/Facilities.tsx | 71 +++++++ .../components/review/write/ImageInput.tsx | 100 ++++++++++ .../components/review/write/Question.tsx | 21 +++ .../components/review/write/ScoreSection.tsx | 45 +++++ src/views/Detail/constants/localStorageKey.ts | 3 + src/views/Detail/pages/WriteReviewPage.tsx | 163 ++++++++++++++++ src/views/Detail/styles/review.ts | 15 ++ src/views/Search/components/Result/Guide.tsx | 4 +- src/views/Search/constants/category.ts | 17 +- src/views/Search/pages/SearchResultPage.tsx | 7 +- 35 files changed, 1416 insertions(+), 20 deletions(-) create mode 100644 src/assets/icon/icon-big-star-fill.svg create mode 100644 src/assets/icon/icon-big-star.svg create mode 100644 src/assets/icon/icon-camera.svg create mode 100644 src/assets/icon/icon-pencil-mono.svg create mode 100644 src/assets/icon/no_reveiw_image.svg create mode 100644 src/assets/icon/small_star.svg create mode 100644 src/assets/icon/toggle-fill-x.svg create mode 100644 src/types/api/review.ts create mode 100644 src/views/Detail/components/Review.tsx create mode 100644 src/views/Detail/components/review/CategoryBottomSheet.tsx create mode 100644 src/views/Detail/components/review/CategoryList.tsx create mode 100644 src/views/Detail/components/review/Guide.tsx create mode 100644 src/views/Detail/components/review/NoReview.tsx create mode 100644 src/views/Detail/components/review/ReviewCard.tsx create mode 100644 src/views/Detail/components/review/SelectedCategory.tsx create mode 100644 src/views/Detail/components/review/TotalReview.tsx create mode 100644 src/views/Detail/components/review/TotalScore.tsx create mode 100644 src/views/Detail/components/review/write/CategoryBottomSheet.tsx create mode 100644 src/views/Detail/components/review/write/Description.tsx create mode 100644 src/views/Detail/components/review/write/ExperienceInput.tsx create mode 100644 src/views/Detail/components/review/write/Facilities.tsx create mode 100644 src/views/Detail/components/review/write/ImageInput.tsx create mode 100644 src/views/Detail/components/review/write/Question.tsx create mode 100644 src/views/Detail/components/review/write/ScoreSection.tsx create mode 100644 src/views/Detail/constants/localStorageKey.ts create mode 100644 src/views/Detail/pages/WriteReviewPage.tsx create mode 100644 src/views/Detail/styles/review.ts 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} +
+
+ + {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 ( +
    + 어떤 경험을 하셨나요? * + 여행지에서 느낀 점을 자세하게 입력해주세요 +