From 0d9d6754aab4dd7198e91998c4de71720958739e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EA=B0=95=ED=98=84?= Date: Wed, 20 Sep 2023 12:37:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=91=90=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EC=9B=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20(#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PetCard -> PetPlantCard 네이밍 변경 * chore: calendar URL 삭제 * chore: 모두의 정원 등록 페이지 URL 추가 * style: 간략화 및 옵션 정렬 변경 * chore: manageLevel 정보없음 포함 * feat: PageDataResponse 추가 및 api.ts로 파일 이름 변경 * chore: 이름 변경내용 적용 * chore: 사용하지 않는 타입 제거 * refactor: PageDataResponse 적용 * design: 날짜 너비 max-content 적용 * design: content 위치 수정 * chore: Image 컴포넌트 적용 * chore: z-index 제거 * feat: 모두의 정원 게시글 데이터 만드는 함수 추가 * feat: 모두의 정원 게시글 모의 API 구현 * feat: 모두의 정원 게시글 API 추가 * chore: 쿼리 key에 파라미터 활용 * design: DaySince, PostingDate에 grayDark적용 * chore: garden 라우팅 추가 * refactor: 길이를 받도록 변경 * refactor: onResultClick에서 id만 보내지 않고 id, name, img 보내도록 확장 * refactor: onResultClick 변경내용 적용 * feat: 검색 결과 리스트를 모달로 변경 * feat: 모두의 정원 게시글 데이터를 페이징으로 받는 커스텀 훅 구현 - useSuspenseInfiniteQuery 적용 * feat: 모두의 정원 페이지 구현 - 헤더에서 사전 식물 필터링 가능 - 무한스크롤로 다음 페이지 렌더링 * chore: Sensor width 제거 * feat: 모두의 정원 게시글 스켈레톤 구현 * feat: 다음 페이지 로딩시 스켈레톤 적용 및 마지막 페이지 표현 * refactor: Suspense 대신 로딩 상태로 스켈레톤 보여주도록 변경 * design: 정원 이름 폰트 크기 늘림 * chore: 페이지에서만 사용하는 파일, 컴포넌트 이동 * refactor: 모두의 정원 헤더 추출 * fix: 모달이 열렸을 때 z-index를 올려서 컨트롤 할 수 있도록 수정 * chore: 주석 제거 * feat: z-index에 dropdownBackdrop추가 및 적용 * chore: transient props 적용 * chore: Skeleton에 ket 적용 * chore: 이벤트 핸들링에 모달 닫기 추가 * chore: 컴포넌트 폴더 밖으로 이동 * fix: 입력값이 없을 때, 포커스해도 결과창이 열리지 않음 * chore: lazy loading 적용 * chore: useDictionaryPlantNavigate로 파일 이름 변경 * fix: 중복 필드 제거 * feat: Tag 영역 가로 스크롤링으로 변경 * style: && 대신 명시적으로 3항연산자 사용 * feat: SearchBox에서 height, fontSize 받을 수 있도록 수정 * design: 필터링 영역 디자인 수정 * feat: 모두의 정원 게시글 MSW 필터링 추가 * chore: 이모지 변경 * refactor: 모달 열기 검증을 함수 대신 상태로 변경 * design: 태그 갭 줄임 * refactor: SearchBox를 제어 컴포넌트로 변경 및 필터 제거 시 검색창 비움 * style: 정렬 순서 변경 * chore: type, aria-label 추가 * refactor: gardenHandler에 통합 * chore: GardenPostList import 추가 * feat: line-arrow-right * feat: Navbar 대신 뒤로가기 헤더와 등록하기 버튼 추가 * chore: 쿼리 훅 폴더 이동 * feat: 필터링 된 모두의 정원으로 가기 추가 * design: 뒤로가기 헤더에 블러 배경 추가 * feat: 모두의 정원 글쓰기 버튼 추가 * design: height 제거 * chore: aria-label 추가 * feat: 로그인을 안하면 토스트 보여줌 * chore: onEnter가 있을 때 close * feat: 토스트 보여주는 개수 제한 * chore: BottomSheet로 네이밍 변경 * test: 비로그인 리마인더 비활성화 --- frontend/cypress/e2e/auth.cy.ts | 8 +- frontend/src/apis/garden.ts | 14 +- .../SeeMoreContentBox.styles.ts | 1 - .../@common/SvgIcons/SvgFill/index.tsx | 1 + .../@common/SvgIcons/SvgSpriteMap.tsx | 14 +- .../@common/SvgIcons/SvgStroke/index.tsx | 1 + .../components/@common/Toast/ToastList.tsx | 4 +- .../DictionaryPlantContent.style.ts | 19 ++- .../DictionaryPlantContent/index.tsx | 31 +++- .../{Skeleton.tsx => TimelineSkeleton.tsx} | 7 +- .../components/petPlant/Timeline/converter.ts | 5 +- .../components/petPlant/Timeline/index.tsx | 6 +- .../search/SearchBox/SearchBox.style.ts | 87 +++++++---- .../src/components/search/SearchBox/index.tsx | 109 +++++++++----- frontend/src/constants/index.ts | 4 +- .../useDictionaryPlantSearch.ts | 2 +- .../hooks/queries/garden/useGardenPostList.ts | 29 ++++ .../src/hooks/queries/history/useYearList.ts | 28 ++-- .../queries/petPlant/usePetPlantCardList.ts | 2 +- .../hooks/queries/reminder/useChangeDate.ts | 2 +- .../src/hooks/queries/reminder/useReminder.ts | 2 +- .../src/hooks/queries/reminder/useWater.ts | 2 +- ...igate.ts => useDictionaryPlantNavigate.ts} | 6 +- frontend/src/mocks/browser.ts | 2 +- frontend/src/mocks/data/garden.ts | 142 ++++++++++++++++++ frontend/src/mocks/handlers/garden.ts | 18 --- frontend/src/mocks/handlers/gardenHandlers.ts | 39 +++++ frontend/src/mocks/storage/Reminder.ts | 2 +- .../DictionaryPlantDetail.style.ts | 53 +++++++ .../src/pages/DictionaryPlantDetail/index.tsx | 39 ++++- .../src/pages/DictionaryPlantSearch/index.tsx | 6 +- .../GardenPostItem/GardenPostItem.stories.tsx | 0 .../GardenPostItem/GardenPostItem.styles.ts | 80 +++++++--- .../GardenPostItem/GardenPostItemSkeleton.tsx | 52 +++++++ .../GardenPostList}/GardenPostItem/index.tsx | 23 +-- .../GardenPostList/GardenPostList.style.ts | 56 +++++++ .../GardenPostListHeader.style.ts | 35 +++++ .../GardenPostListHeader/index.tsx | 47 ++++++ frontend/src/pages/GardenPostList/index.tsx | 80 ++++++++++ frontend/src/pages/Main/index.tsx | 6 +- .../PetPlantCardList.style.ts | 2 - .../pages/PetPlantRegister/Form/Form.style.ts | 3 +- .../pages/PetPlantRegister/Search/index.tsx | 12 +- frontend/src/router.tsx | 5 + frontend/src/store/atoms/garden.ts | 9 ++ frontend/src/style/theme.style.ts | 3 +- .../src/types/{DataResponse.ts => api.ts} | 7 + frontend/src/types/history.ts | 8 - 48 files changed, 931 insertions(+), 182 deletions(-) rename frontend/src/components/petPlant/Timeline/{Skeleton.tsx => TimelineSkeleton.tsx} (78%) create mode 100644 frontend/src/hooks/queries/garden/useGardenPostList.ts rename frontend/src/hooks/{useDictionaryNavigate.ts => useDictionaryPlantNavigate.ts} (84%) create mode 100644 frontend/src/mocks/data/garden.ts delete mode 100644 frontend/src/mocks/handlers/garden.ts create mode 100644 frontend/src/mocks/handlers/gardenHandlers.ts rename frontend/src/{components/garden => pages/GardenPostList}/GardenPostItem/GardenPostItem.stories.tsx (100%) rename frontend/src/{components/garden => pages/GardenPostList}/GardenPostItem/GardenPostItem.styles.ts (62%) create mode 100644 frontend/src/pages/GardenPostList/GardenPostItem/GardenPostItemSkeleton.tsx rename frontend/src/{components/garden => pages/GardenPostList}/GardenPostItem/index.tsx (86%) create mode 100644 frontend/src/pages/GardenPostList/GardenPostList.style.ts create mode 100644 frontend/src/pages/GardenPostList/GardenPostListHeader/GardenPostListHeader.style.ts create mode 100644 frontend/src/pages/GardenPostList/GardenPostListHeader/index.tsx create mode 100644 frontend/src/pages/GardenPostList/index.tsx create mode 100644 frontend/src/store/atoms/garden.ts rename frontend/src/types/{DataResponse.ts => api.ts} (72%) diff --git a/frontend/cypress/e2e/auth.cy.ts b/frontend/cypress/e2e/auth.cy.ts index f663ee994..ba813df13 100644 --- a/frontend/cypress/e2e/auth.cy.ts +++ b/frontend/cypress/e2e/auth.cy.ts @@ -1,10 +1,10 @@ import login from '../utils/login'; describe('비로그인 상태에서는 로그인 페이지로 이동한다.', () => { - it('리마인더', () => { - cy.visit('/reminder'); - cy.get('#toast-root').contains('로그인 후 이용 가능').url().should('match', /login/); - }); + // it('리마인더', () => { + // cy.visit('/reminder'); + // cy.get('#toast-root').contains('로그인 후 이용 가능').url().should('match', /login/); + // }); it('내 반려 식물 목록', () => { cy.visit('/pet'); diff --git a/frontend/src/apis/garden.ts b/frontend/src/apis/garden.ts index 649b0a226..6f71201b0 100644 --- a/frontend/src/apis/garden.ts +++ b/frontend/src/apis/garden.ts @@ -1,12 +1,23 @@ +import type { DictionaryPlant } from 'types/dictionaryPlant'; import type { GardenRegisterForm } from 'types/garden'; import { BASE_URL } from 'constants/index'; -export const GARDEN_URL = `${BASE_URL}/garden`; +export const GARDEN_URL = `${BASE_URL}/garden` as const; const headers = { 'Content-Type': 'application/json', }; +const getList = (dictionaryPlantId: DictionaryPlant['id'] | null, page: number) => { + let url = `${GARDEN_URL}?page=${page}`; + + if (dictionaryPlantId) { + url += `&dictionaryPlantId=${dictionaryPlantId}`; + } + + return fetch(url); +}; + const register = (form: GardenRegisterForm) => { return fetch(GARDEN_URL, { method: 'POST', @@ -17,6 +28,7 @@ const register = (form: GardenRegisterForm) => { }; const GardenAPI = { + getList, register, }; diff --git a/frontend/src/components/@common/SeeMoreContentBox/SeeMoreContentBox.styles.ts b/frontend/src/components/@common/SeeMoreContentBox/SeeMoreContentBox.styles.ts index 29a9620e4..86b826abb 100644 --- a/frontend/src/components/@common/SeeMoreContentBox/SeeMoreContentBox.styles.ts +++ b/frontend/src/components/@common/SeeMoreContentBox/SeeMoreContentBox.styles.ts @@ -14,7 +14,6 @@ export const ContentBox = styled.div<{ $hiddenOver: boolean; $maxHeight: string export const SeeMoreButtonArea = styled.div` position: absolute; - z-index: ${(props) => props.theme.zIndex.fixed}; bottom: 0; display: flex; diff --git a/frontend/src/components/@common/SvgIcons/SvgFill/index.tsx b/frontend/src/components/@common/SvgIcons/SvgFill/index.tsx index 6c1aaef70..19c7d5f78 100644 --- a/frontend/src/components/@common/SvgIcons/SvgFill/index.tsx +++ b/frontend/src/components/@common/SvgIcons/SvgFill/index.tsx @@ -19,6 +19,7 @@ export const ICONS = [ 'humidity', 'info-circle', 'line-arrow-left', + 'line-arrow-right', 'manage-level-정보없음', 'manage-level-초보자', 'manage-level-경험자', diff --git a/frontend/src/components/@common/SvgIcons/SvgSpriteMap.tsx b/frontend/src/components/@common/SvgIcons/SvgSpriteMap.tsx index 9aa459ffe..5c6800cc2 100644 --- a/frontend/src/components/@common/SvgIcons/SvgSpriteMap.tsx +++ b/frontend/src/components/@common/SvgIcons/SvgSpriteMap.tsx @@ -87,6 +87,9 @@ const SvgIcons = () => ( + + + @@ -99,14 +102,12 @@ const SvgIcons = () => ( - - {' '} @@ -182,6 +183,15 @@ const SvgIcons = () => ( + + + ); diff --git a/frontend/src/components/@common/SvgIcons/SvgStroke/index.tsx b/frontend/src/components/@common/SvgIcons/SvgStroke/index.tsx index 1b7a5628d..82a51dd8c 100644 --- a/frontend/src/components/@common/SvgIcons/SvgStroke/index.tsx +++ b/frontend/src/components/@common/SvgIcons/SvgStroke/index.tsx @@ -8,6 +8,7 @@ export const ICONS = [ 'bulletin-board-line', 'reminder', 'dictionary', + 'plus', ] as const; type IconIds = (typeof ICONS)[number]; diff --git a/frontend/src/components/@common/Toast/ToastList.tsx b/frontend/src/components/@common/Toast/ToastList.tsx index b3c50daae..285bf7164 100644 --- a/frontend/src/components/@common/Toast/ToastList.tsx +++ b/frontend/src/components/@common/Toast/ToastList.tsx @@ -5,8 +5,10 @@ import { ToastListWrapper } from './Toast.style'; import toasts from 'store/atoms/toasts'; import Toast from '.'; +const SHOW_TOAST_SIZE = 5; + const ToastList = () => { - const toastList = useRecoilValue(toasts); + const toastList = useRecoilValue(toasts).slice(-SHOW_TOAST_SIZE); const root = document.getElementById('toast-root') ?? document.body; return createPortal( diff --git a/frontend/src/components/dictionaryPlant/DictionaryPlantContent/DictionaryPlantContent.style.ts b/frontend/src/components/dictionaryPlant/DictionaryPlantContent/DictionaryPlantContent.style.ts index 52fc55334..2e724af3c 100644 --- a/frontend/src/components/dictionaryPlant/DictionaryPlantContent/DictionaryPlantContent.style.ts +++ b/frontend/src/components/dictionaryPlant/DictionaryPlantContent/DictionaryPlantContent.style.ts @@ -12,6 +12,12 @@ export const HeaderBox = styled.section` border-bottom: 1px solid ${(props) => props.theme.color.grayLight}; `; +export const Name = styled.p` + font: 900 2.4rem/4rem 'GmarketSans'; + color: ${(props) => props.theme.color.sub}; + text-align: left; +`; + export const FamilyName = styled.p` margin-bottom: 4px; font: 500 1.2rem/1.6rem 'GmarketSans'; @@ -19,10 +25,17 @@ export const FamilyName = styled.p` text-align: left; `; -export const Name = styled.p` - font: 900 2.4rem/4rem 'GmarketSans'; +export const GardenButton = styled.button` + display: flex; + column-gap: 4px; + align-items: center; + + height: 24px; + margin-top: 16px; + + font-size: 1.4rem; + font-weight: 600; color: ${(props) => props.theme.color.sub}; - text-align: left; `; export const ContentBox = styled.section` diff --git a/frontend/src/components/dictionaryPlant/DictionaryPlantContent/index.tsx b/frontend/src/components/dictionaryPlant/DictionaryPlantContent/index.tsx index 06dd1786c..77d55a610 100644 --- a/frontend/src/components/dictionaryPlant/DictionaryPlantContent/index.tsx +++ b/frontend/src/components/dictionaryPlant/DictionaryPlantContent/index.tsx @@ -1,11 +1,16 @@ +import { useNavigate } from 'react-router'; +import { useSetRecoilState } from 'recoil'; import SeeMoreContentBox from 'components/@common/SeeMoreContentBox'; import SvgIcons from 'components/@common/SvgIcons/SvgFill'; +import SvgFill from 'components/@common/SvgIcons/SvgFill'; +import SvgStroke from 'components/@common/SvgIcons/SvgStroke'; import TagBox from 'components/dictionaryPlant/TagBox'; import TagSwitch from 'components/dictionaryPlant/TagSwitch'; import { Accent, ContentBox, FamilyName, + GardenButton, HeaderBox, InformationTagBox, ManageInfoBox, @@ -13,16 +18,19 @@ import { PropBox, PropsBox, } from './DictionaryPlantContent.style'; +import selectedDictionaryPlantAtom from 'store/atoms/garden'; import type { DictionaryPlantExtendCycles } from 'hooks/queries/dictionaryPlant/useDictionaryPlantDetail'; import parseTemperature from 'utils/parseTemperature'; -import { NO_INFORMATION } from 'constants/index'; +import { NO_INFORMATION, URL_PATH } from 'constants/index'; import theme from 'style/theme.style'; const DictionaryPlantContent = (props: DictionaryPlantExtendCycles) => { const { + id, postingPlace, familyName, name, + image, manageLevel, growSpeed, requireHumidity, @@ -34,6 +42,18 @@ const DictionaryPlantContent = (props: DictionaryPlantExtendCycles) => { waterOptions, } = props; + const setSelectedDictionaryPlant = useSetRecoilState(selectedDictionaryPlantAtom); + const navigate = useNavigate(); + + const goFilteredGarden = () => { + setSelectedDictionaryPlant({ + id, + name, + image, + }); + navigate(URL_PATH.garden); + }; + const place = postingPlace.map((position, idx) => { const text = position === NO_INFORMATION ? '제공된 정보가 없어요😢' : position; return {text}; @@ -47,6 +67,15 @@ const DictionaryPlantContent = (props: DictionaryPlantExtendCycles) => {
{name} {familyName} + + + 모두의 정원 + +
diff --git a/frontend/src/components/petPlant/Timeline/Skeleton.tsx b/frontend/src/components/petPlant/Timeline/TimelineSkeleton.tsx similarity index 78% rename from frontend/src/components/petPlant/Timeline/Skeleton.tsx rename to frontend/src/components/petPlant/Timeline/TimelineSkeleton.tsx index 25401e359..b8c0d5fcc 100644 --- a/frontend/src/components/petPlant/Timeline/Skeleton.tsx +++ b/frontend/src/components/petPlant/Timeline/TimelineSkeleton.tsx @@ -8,13 +8,14 @@ import { } from './Timeline.style'; interface SkeletonProps { + length: number; hasYearHeader?: boolean; } -const Skeleton = ({ hasYearHeader }: SkeletonProps) => ( +const TimelineSkeleton = ({ length, hasYearHeader }: SkeletonProps) => ( <> {hasYearHeader && } - {Array(10) + {Array(length) .fill(null) .map((_, index) => ( @@ -29,4 +30,4 @@ const Skeleton = ({ hasYearHeader }: SkeletonProps) => ( ); -export default Skeleton; +export default TimelineSkeleton; diff --git a/frontend/src/components/petPlant/Timeline/converter.ts b/frontend/src/components/petPlant/Timeline/converter.ts index 43bc00000..c20efff49 100644 --- a/frontend/src/components/petPlant/Timeline/converter.ts +++ b/frontend/src/components/petPlant/Timeline/converter.ts @@ -1,4 +1,5 @@ -import type { HistoryItem, HistoryResponse } from 'types/history'; +import type { PageDataResponse } from 'types/api'; +import type { HistoryItem } from 'types/history'; export interface TimelineItem { type: HistoryItem['type']; @@ -15,7 +16,7 @@ type MonthList = [string, DayList][]; export type YearList = [string, MonthList][]; export const convertHistoryResponseListToHistoryItemList = ( - historyResponseList: HistoryResponse[] + historyResponseList: PageDataResponse[] ) => historyResponseList.reduce( (accWaterDateList, page) => accWaterDateList.concat(page.data), diff --git a/frontend/src/components/petPlant/Timeline/index.tsx b/frontend/src/components/petPlant/Timeline/index.tsx index 8c2b27ffc..0d786d4a6 100644 --- a/frontend/src/components/petPlant/Timeline/index.tsx +++ b/frontend/src/components/petPlant/Timeline/index.tsx @@ -22,7 +22,7 @@ import useIntersectionRef from 'hooks/useIntersectionRef'; import SproutSvg from 'assets/sprout.svg'; import SproutWebp from 'assets/sprout.webp'; import TimelineItemList from '../TimelineItemList'; -import Skeleton from './Skeleton'; +import TimelineSkeleton from './TimelineSkeleton'; interface TimelineProps { petPlantId: PetPlantDetails['id']; @@ -76,9 +76,9 @@ const Timeline = ({ petPlantId, filter }: TimelineProps) => { )) ) : ( - + )} - {isFetchingNextPage ? : } + {isFetchingNextPage ? : } {!hasNextPage && } ); diff --git a/frontend/src/components/search/SearchBox/SearchBox.style.ts b/frontend/src/components/search/SearchBox/SearchBox.style.ts index d05b90142..30b18b7c5 100644 --- a/frontend/src/components/search/SearchBox/SearchBox.style.ts +++ b/frontend/src/components/search/SearchBox/SearchBox.style.ts @@ -1,35 +1,77 @@ import { Link } from 'react-router-dom'; import styled from 'styled-components'; -export const Wrapper = styled.div` +export const Wrapper = styled.div<{ $fontSize: string }>` + position: relative; + display: flex; flex-direction: column; width: 100%; - height: min-content; - border: solid 2px ${(p) => p.theme.color.primary}; - border-radius: 29px; + font-size: ${(props) => props.$fontSize}; `; -export const InputArea = styled.div` +export const InputBox = styled.div<{ $openBottom: boolean; $height: string }>` + z-index: ${(props) => (props.$openBottom ? props.theme.zIndex.dropdown : 'auto')}; + display: flex; align-items: center; + padding: 0 12px; + + border: solid 2px ${(props) => props.theme.color.primary}; + border-radius: ${({ $height, $openBottom }) => { + const half = `calc(${$height} / 2)`; + return $openBottom ? `${half} ${half} 0 0` : half; + }}; `; -export const Input = styled.input` - width: 100%; - height: 32px; - margin: 12px 0; - margin-left: 8px; +export const Input = styled.input<{ $height: string }>` + display: flex; + align-items: center; - font-size: 2rem; + width: calc(100% - 72px); + height: ${(props) => props.$height}; + margin: 0 8px; + + font-size: inherit; border: none; outline: none; `; +export const Backdrop = styled.div` + position: fixed; + z-index: ${({ theme: { zIndex } }) => zIndex.dropdownBackdrop}; + top: 0; + left: 0; + + width: 100%; + height: 100%; +`; + +export const ResultDropdown = styled.div<{ $height: string }>` + position: absolute; + z-index: ${(props) => props.theme.zIndex.dropdown}; + bottom: 2px; + transform: translateY(100%); + + width: 100%; + + background-color: ${(props) => props.theme.color.background}; + border: solid 2px ${(props) => props.theme.color.primary}; + border-top: 0px; + border-radius: ${({ $height }) => `0 0 calc(${$height} / 2) calc(${$height} / 2)`}; +`; + +export const ResultList = styled.ul<{ $maxHeight: string }>` + overflow-x: none; + overflow-y: auto; + width: 100%; + max-height: ${(props) => props.$maxHeight}; +`; + export const ResultMessage = styled.p` display: flex; flex-direction: column; @@ -38,39 +80,30 @@ export const ResultMessage = styled.p` justify-content: center; width: 100%; - height: 56px; + padding: 8px 0; - font-size: 1.8rem; - color: ${(p) => p.theme.color.sub}; + color: ${(props) => props.theme.color.sub}; text-align: center; - border-top: solid 2px ${(p) => p.theme.color.primary + '40'}; -`; - -export const ResultList = styled.ul` - overflow-x: none; - overflow-y: auto; - width: 100%; - max-height: 336px; + border-top: solid 2px ${(props) => props.theme.color.primary + '40'}; `; -export const ResultItem = styled.li` +export const ResultItem = styled.li<{ $height: string }>` cursor: pointer; display: flex; align-items: center; width: 100%; - height: 56px; + height: ${(props) => props.$height}; padding-left: 12px; - border-top: solid 2px ${(p) => p.theme.color.primary + '40'}; + border-top: solid 2px ${(props) => props.theme.color.primary + '40'}; `; export const Name = styled.p` margin-left: 12px; - font-size: 1.8rem; - color: ${(p) => p.theme.color.sub}; + color: ${(props) => props.theme.color.sub}; `; export const EnterButton = styled.button` diff --git a/frontend/src/components/search/SearchBox/index.tsx b/frontend/src/components/search/SearchBox/index.tsx index 472bae936..cb58f44cf 100644 --- a/frontend/src/components/search/SearchBox/index.tsx +++ b/frontend/src/components/search/SearchBox/index.tsx @@ -1,9 +1,8 @@ import type { DictionaryPlantNameSearchResult } from 'types/dictionaryPlant'; -import { useState } from 'react'; import Image from 'components/@common/Image'; import SvgIcons from 'components/@common/SvgIcons/SvgFill'; import { - InputArea, + InputBox, ResultItem, ResultList, Wrapper, @@ -12,89 +11,121 @@ import { Input, ResultMessage, StyledLink, + Backdrop, + ResultDropdown, } from './SearchBox.style'; import useDictionaryPlantSearch from 'hooks/queries/dictionaryPlant/useDictionaryPlantSearch'; import useDebounce from 'hooks/useDebounce'; +import useToggle from 'hooks/useToggle'; import { MESSAGE, URL_PATH } from 'constants/index'; import theme from 'style/theme.style'; interface SearchBoxProps { - onResultClick?: (id: number) => void; + value: string; + height?: `${number}px`; + fontSize?: string; + showResultSize?: number; + onChangeValue: (value: string) => void; + onResultClick?: (searchResult: DictionaryPlantNameSearchResult) => void; onEnter?: (name: string, searchResults?: DictionaryPlantNameSearchResult[]) => void; onNextClick?: (name: string, searchResults?: DictionaryPlantNameSearchResult[]) => void; } const SearchBox = (props: SearchBoxProps) => { - const { onResultClick, onEnter, onNextClick } = props; - - const [searchName, setSearchName] = useState(''); - const queryName = useDebounce(searchName, 200); - + const { + value, + height = '56px', + fontSize = '2rem', + showResultSize = 4, + onChangeValue, + onResultClick, + onEnter, + onNextClick, + } = props; + const queryName = useDebounce(value, 200); const { data: searchResults } = useDictionaryPlantSearch(queryName); + const { isOn, on: open, off: close } = useToggle(); + + const isOpen = value !== '' && isOn; + const numberHeight = Number(height.slice(0, -2)); const handleSearchNameChange: React.ChangeEventHandler = ({ target: { value }, }) => { - setSearchName(value); + onChangeValue(value); + open(); }; const searchOnEnter: React.ComponentProps<'input'>['onKeyDown'] = ({ key }) => { - if (key !== 'Enter') return; - onEnter?.(searchName, searchResults); + if (key !== 'Enter' || !onEnter) return; + onEnter(value, searchResults); + close(); }; - const handleResultClick = (plantId: number) => () => { - onResultClick?.(plantId); + const handleResultClick = (searchResult: DictionaryPlantNameSearchResult) => () => { + onResultClick?.(searchResult); + onChangeValue(searchResult.name); + close(); }; const handleNextButtonClick = () => { - onNextClick?.(searchName, searchResults); + onNextClick?.(value, searchResults); + close(); }; - const hasSearchResult = searchResults && searchName !== ''; + const handleFocus = () => { + open(); + }; return ( - - - + + + {onNextClick && ( - + )} - - {hasSearchResult && - (searchResults.length ? ( - <> - - {searchResults.map(({ id, name, image }) => ( - - {name} + + {isOpen && ( + <> + + + + {searchResults?.map(({ id, name, image }) => ( + + {name} {name} ))} - 찾는 식물이 없으신가요? - + {searchResults?.length ? '찾는 식물이 없으신가요?' : MESSAGE.noSearchResult} + 등록 신청하기 - - ) : ( - - {MESSAGE.noSearchResult} - - 등록 신청하기 - - - ))} + + + )} ); }; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index de09c74d1..160ebbfb9 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -12,13 +12,13 @@ export const URL_PATH = { petEdit: '/pet/:id/edit', petRegisterSearch: '/pet/register', petRegisterForm: '/pet/register/:id', + reminder: '/reminder', + garden: '/garden', gardenRegisterPick: '/garden/register', gardenRegisterForm: '/garden/register/:id', - reminder: '/reminder', login: '/login', authorization: '/authorization', myPage: '/myPage', - garden: '/garden', newDictionaryPlantRequest: '/dict/new-plant-request', } as const; diff --git a/frontend/src/hooks/queries/dictionaryPlant/useDictionaryPlantSearch.ts b/frontend/src/hooks/queries/dictionaryPlant/useDictionaryPlantSearch.ts index 056870107..fd58c7aee 100644 --- a/frontend/src/hooks/queries/dictionaryPlant/useDictionaryPlantSearch.ts +++ b/frontend/src/hooks/queries/dictionaryPlant/useDictionaryPlantSearch.ts @@ -1,4 +1,4 @@ -import type { DataResponse } from 'types/DataResponse'; +import type { DataResponse } from 'types/api'; import type { DictionaryPlantNameSearchResult } from 'types/dictionaryPlant'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import DictionaryPlantAPI, { DICTIONARY_PLANT_URL } from 'apis/dictionaryPlant'; diff --git a/frontend/src/hooks/queries/garden/useGardenPostList.ts b/frontend/src/hooks/queries/garden/useGardenPostList.ts new file mode 100644 index 000000000..32121997d --- /dev/null +++ b/frontend/src/hooks/queries/garden/useGardenPostList.ts @@ -0,0 +1,29 @@ +import type { PageDataResponse } from 'types/api'; +import type { GardenPostItem } from 'types/garden'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import GardenAPI, { GARDEN_URL } from 'apis/garden'; +import throwOnInvalidStatus from 'utils/throwOnInvalidStatus'; + +const useGardenPostList = (dictionaryPlantId: number | null) => + useInfiniteQuery< + PageDataResponse, + Error, + GardenPostItem[], + [typeof GARDEN_URL, typeof dictionaryPlantId], + number + >({ + queryKey: [GARDEN_URL, dictionaryPlantId], + queryFn: async ({ pageParam }) => { + const response = await GardenAPI.getList(dictionaryPlantId, pageParam); + throwOnInvalidStatus(response); + return response.json(); + }, + + initialPageParam: 0, + getNextPageParam: ({ hasNext }, _, lastPageParam) => (hasNext ? lastPageParam + 1 : null), + + throwOnError: true, + select: ({ pages }) => pages.reduce((acc, { data }) => acc.concat(data), []), + }); + +export default useGardenPostList; diff --git a/frontend/src/hooks/queries/history/useYearList.ts b/frontend/src/hooks/queries/history/useYearList.ts index 32d4e5cef..38233e7dc 100644 --- a/frontend/src/hooks/queries/history/useYearList.ts +++ b/frontend/src/hooks/queries/history/useYearList.ts @@ -1,4 +1,5 @@ -import type { HistoryResponse, HistoryType } from 'types/history'; +import type { PageDataResponse } from 'types/api'; +import type { HistoryType, HistoryItem } from 'types/history'; import type { PetPlantDetails } from 'types/petPlant'; import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; import { @@ -13,26 +14,27 @@ import throwOnInvalidStatus from 'utils/throwOnInvalidStatus'; const useYearList = (petPlantId: PetPlantDetails['id'], filter: HistoryType[] = []) => useInfiniteQuery< - HistoryResponse, + PageDataResponse, Error, YearList, - [typeof HISTORY_URL, PetPlantDetails['id'], HistoryType[]], + [typeof HISTORY_URL, typeof petPlantId, typeof filter], number >({ queryKey: [HISTORY_URL, petPlantId, filter], queryFn: async ({ pageParam }) => { const response = await HistoryAPI.getPetPlant(petPlantId, pageParam, 20, filter); - throwOnInvalidStatus(response); - - const data = await response.json(); - return data; + return response.json(); }, initialPageParam: 0, - getNextPageParam: ({ hasNext }, _allPages, lastPageParam) => { - return hasNext ? lastPageParam + 1 : undefined; - }, + getNextPageParam: ({ hasNext }, _, lastPageParam) => (hasNext ? lastPageParam + 1 : null), + + throwOnError: true, + retry: noRetryIfUnauthorized, + refetchOnWindowFocus: false, + placeholderData: keepPreviousData, + gcTime: 0, select: (data) => { const historyItemList = convertHistoryResponseListToHistoryItemList(data.pages); @@ -40,12 +42,6 @@ const useYearList = (petPlantId: PetPlantDetails['id'], filter: HistoryType[] = const yearList = convertYearMapToYearList(yearMap); return yearList; }, - - throwOnError: true, - retry: noRetryIfUnauthorized, - refetchOnWindowFocus: false, - placeholderData: keepPreviousData, - gcTime: 0, }); export default useYearList; diff --git a/frontend/src/hooks/queries/petPlant/usePetPlantCardList.ts b/frontend/src/hooks/queries/petPlant/usePetPlantCardList.ts index f3133c553..2e57481c4 100644 --- a/frontend/src/hooks/queries/petPlant/usePetPlantCardList.ts +++ b/frontend/src/hooks/queries/petPlant/usePetPlantCardList.ts @@ -1,4 +1,4 @@ -import type { DataResponse } from 'types/DataResponse'; +import type { DataResponse } from 'types/api'; import type { PetPlantItem } from 'types/petPlant'; import { useSuspenseQuery } from '@tanstack/react-query'; import PetPlantAPI, { PET_PLANT_URL } from 'apis/petPlant'; diff --git a/frontend/src/hooks/queries/reminder/useChangeDate.ts b/frontend/src/hooks/queries/reminder/useChangeDate.ts index b0ed5da41..5bada039d 100644 --- a/frontend/src/hooks/queries/reminder/useChangeDate.ts +++ b/frontend/src/hooks/queries/reminder/useChangeDate.ts @@ -1,4 +1,4 @@ -import type { MutationProps } from 'types/DataResponse'; +import type { MutationProps } from 'types/api'; import type { ChangeDateParams } from 'types/reminder'; import { useMutation } from '@tanstack/react-query'; import ReminderAPI from 'apis/reminder'; diff --git a/frontend/src/hooks/queries/reminder/useReminder.ts b/frontend/src/hooks/queries/reminder/useReminder.ts index bcaf791a3..1cd66fb3f 100644 --- a/frontend/src/hooks/queries/reminder/useReminder.ts +++ b/frontend/src/hooks/queries/reminder/useReminder.ts @@ -1,4 +1,4 @@ -import type { DataResponse } from 'types/DataResponse'; +import type { DataResponse } from 'types/api'; import type { Month } from 'types/date'; import type { Reminder, ReminderExtendType, TodayStatus } from 'types/reminder'; import { useSuspenseQuery } from '@tanstack/react-query'; diff --git a/frontend/src/hooks/queries/reminder/useWater.ts b/frontend/src/hooks/queries/reminder/useWater.ts index 1fd8e896f..c8ced05a5 100644 --- a/frontend/src/hooks/queries/reminder/useWater.ts +++ b/frontend/src/hooks/queries/reminder/useWater.ts @@ -1,4 +1,4 @@ -import type { MutationProps } from 'types/DataResponse'; +import type { MutationProps } from 'types/api'; import type { WaterPlantParams } from 'types/reminder'; import { useMutation } from '@tanstack/react-query'; import ReminderAPI from 'apis/reminder'; diff --git a/frontend/src/hooks/useDictionaryNavigate.ts b/frontend/src/hooks/useDictionaryPlantNavigate.ts similarity index 84% rename from frontend/src/hooks/useDictionaryNavigate.ts rename to frontend/src/hooks/useDictionaryPlantNavigate.ts index 8341c2384..d07eaa6c0 100644 --- a/frontend/src/hooks/useDictionaryNavigate.ts +++ b/frontend/src/hooks/useDictionaryPlantNavigate.ts @@ -7,8 +7,8 @@ const useDictionaryPlantNavigate = () => { const navigate = useNavigate(); const goToDictionaryPlantDetailPage = useCallback( - (plantId: number) => { - navigate(generatePath(URL_PATH.dictDetail, { id: plantId.toString() })); + ({ id }: DictionaryPlantNameSearchResult) => { + navigate(generatePath(URL_PATH.dictDetail, { id: id.toString() })); }, [navigate] ); @@ -24,7 +24,7 @@ const useDictionaryPlantNavigate = () => { return; } - goToDictionaryPlantDetailPage(samePlant.id); + goToDictionaryPlantDetailPage(samePlant); }, [navigate, goToDictionaryPlantDetailPage] ); diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts index cfe0af969..890a21be4 100644 --- a/frontend/src/mocks/browser.ts +++ b/frontend/src/mocks/browser.ts @@ -1,7 +1,7 @@ import { setupWorker } from 'msw'; import { makeHandler } from './handlers'; import dictionaryPlantRegistrationHandlers from './handlers/dictionaryPlantRegistration'; -import gardenHandlers from './handlers/garden'; +import gardenHandlers from './handlers/gardenHandlers'; import historyHandlers from './handlers/historyHandlers'; export const worker = setupWorker( diff --git a/frontend/src/mocks/data/garden.ts b/frontend/src/mocks/data/garden.ts new file mode 100644 index 000000000..27162c988 --- /dev/null +++ b/frontend/src/mocks/data/garden.ts @@ -0,0 +1,142 @@ +import { OPTIONS } from 'constants/index'; + +const DICTIONARY_PLANT_MAP: Record = { + 1: '아', + 2: '아카', + 3: '아카시', + 4: '아카시아', + 5: '참새', +}; + +export const generateGardenPageData = ( + dictionaryPlantId: number | null, + pageParam: number, + hasNext: boolean +) => { + const page = hasNext + ? [ + { + id: pageParam * 100 + 1, + createdAt: '1999-12-18', + updatedAt: '1999-12-18', + dictionaryPlantName: '아', + content: '이거 이렇게 키워보아요', + manageLevel: '초보자', + petPlant: { + imageUrl: 'https://images.unsplash.com/photo-1598983062491-5934ce558814', + nickname: '루피', + location: OPTIONS.location[0], + flowerpot: OPTIONS.flowerPot[0], + light: OPTIONS.light[0], + wind: OPTIONS.wind[0], + daySince: 95, + waterCycle: 1, + }, + }, + { + id: pageParam * 100 + 2, + createdAt: '1999-12-17', + updatedAt: '1999-12-17', + dictionaryPlantName: '아카', + content: '이거 이렇게 키워보아요', + manageLevel: '전문가', + petPlant: { + imageUrl: 'https://images.unsplash.com/photo-1598983062491-5934ce558814', + nickname: '쵸파', + location: OPTIONS.location[0], + flowerpot: OPTIONS.flowerPot[0], + light: OPTIONS.light[0], + wind: OPTIONS.wind[0], + daySince: 10, + waterCycle: 7, + }, + }, + { + id: pageParam * 100 + 3, + createdAt: '1999-12-16', + updatedAt: '1999-12-16', + dictionaryPlantName: '아카시', + content: '이거 이렇게 키워보아요', + manageLevel: '초보자', + petPlant: { + imageUrl: 'https://images.unsplash.com/photo-1598983062491-5934ce558814', + nickname: '토마토1호', + location: OPTIONS.location[0], + flowerpot: OPTIONS.flowerPot[0], + light: OPTIONS.light[0], + wind: OPTIONS.wind[0], + daySince: 49, + waterCycle: 7, + }, + }, + { + id: pageParam * 100 + 4, + createdAt: '1999-12-15', + updatedAt: '1999-12-15', + dictionaryPlantName: '아카시아', + content: ` + 제가 LA에 있을때는 말이죠 정말 제가 꿈에 무대인 메이저리그로 진출해서 가는 식당마다 싸인해달라 기자들은 항상 붙어다니며 취재하고 제가 그 머~ 어~ 대통령이 된 기분이였어요 + 그런데 17일만에 17일만에 마이너리그로 떨어졌어요 못던져서 그만두고 그냥 확 한국으로 가버리고 싶었어요 + 그래서 집에 가는길에 그 맥주6개 달린거 있잖아요 맥주6개 그걸 사가지고 집으로 갔어요 그전에는 술먹으면 야구 못하는줄 알았어요 그냥 한국으로 가버릴려구.... + 그리고 맥주 6개먹고 확 죽어버릴려고 그랬어요 야구 못하게 되니깐 그러나 집에가서 일단은 부모님에게 전화를 해야겠다고 생각을 했어요 + `, + manageLevel: '초보자', + petPlant: { + imageUrl: 'https://images.unsplash.com/photo-1598983062491-5934ce558814', + nickname: '뉴기니아봉선화', + location: OPTIONS.location[0], + flowerpot: OPTIONS.flowerPot[0], + light: OPTIONS.light[0], + wind: OPTIONS.wind[0], + daySince: 40, + waterCycle: 7, + }, + }, + { + id: pageParam * 100 + 5, + createdAt: '1999-12-14', + updatedAt: '1999-12-14', + dictionaryPlantName: '참새', + content: '이거 이렇게 키워보아요', + manageLevel: '초보자', + petPlant: { + imageUrl: 'https://images.unsplash.com/photo-1598983062491-5934ce558814', + nickname: '상디', + location: OPTIONS.location[0], + flowerpot: OPTIONS.flowerPot[0], + light: OPTIONS.light[0], + wind: OPTIONS.wind[0], + daySince: 30, + waterCycle: 7, + }, + }, + ] + : [ + { + id: 100, + createdAt: '1999-12-11', + updatedAt: '1999-12-11', + dictionaryPlantName: '아카시아', + content: '이거 이렇게 키워보아요', + manageLevel: '초보자', + petPlant: { + imageUrl: 'https://images.unsplash.com/photo-1598983062491-5934ce558814', + nickname: '브룩', + location: OPTIONS.location[0], + flowerpot: OPTIONS.flowerPot[0], + light: OPTIONS.light[0], + wind: OPTIONS.wind[0], + daySince: 95, + waterCycle: 7, + }, + }, + ]; + + if (dictionaryPlantId) { + return page.filter( + ({ dictionaryPlantName }) => DICTIONARY_PLANT_MAP[dictionaryPlantId] === dictionaryPlantName + ); + } + + return page; +}; diff --git a/frontend/src/mocks/handlers/garden.ts b/frontend/src/mocks/handlers/garden.ts deleted file mode 100644 index 96c7fe563..000000000 --- a/frontend/src/mocks/handlers/garden.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { GardenRegisterForm } from 'types/garden'; -import { rest } from 'msw'; - -const GARDEN = '*/garden'; - -const gardenHandlers = [ - rest.post(GARDEN, (req, res, ctx) => { - const { JSESSION } = req.cookies; - - if (JSESSION === undefined) { - return res(ctx.delay(200), ctx.status(401), ctx.json({ message: '만료된 세션입니다.' })); - } - - return res(ctx.delay(200), ctx.status(201)); - }), -]; - -export default gardenHandlers; diff --git a/frontend/src/mocks/handlers/gardenHandlers.ts b/frontend/src/mocks/handlers/gardenHandlers.ts new file mode 100644 index 000000000..17f2b92a5 --- /dev/null +++ b/frontend/src/mocks/handlers/gardenHandlers.ts @@ -0,0 +1,39 @@ +import type { GardenRegisterForm } from 'types/garden'; +import { rest } from 'msw'; +import { generateGardenPageData } from '../data/garden'; + +const GARDEN_PATH = '*/garden'; + +const gardenHandlers = [ + rest.get(GARDEN_PATH, (req, res, ctx) => { + const pageParam = req.url.searchParams.get('page'); + const page = pageParam ? Number(pageParam) : 0; + + const dictionaryPlantIdParam = req.url.searchParams.get('dictionaryPlantId'); + const dictionaryPlantId = dictionaryPlantIdParam ? Number(dictionaryPlantIdParam) : null; + + const hasNext = page < 6; + const data = generateGardenPageData(dictionaryPlantId, page, hasNext); + const response = { + page, + size: data.length, + elementSize: 100, + hasNext, + data, + }; + + return res(ctx.delay(0), ctx.status(200), ctx.json(response)); + }), + + rest.post(GARDEN_PATH, (req, res, ctx) => { + const { JSESSION } = req.cookies; + + if (JSESSION === undefined) { + return res(ctx.delay(200), ctx.status(401), ctx.json({ message: '만료된 세션입니다.' })); + } + + return res(ctx.delay(200), ctx.status(201)); + }), +]; + +export default gardenHandlers; diff --git a/frontend/src/mocks/storage/Reminder.ts b/frontend/src/mocks/storage/Reminder.ts index 626b3c0c0..c4cc7e59c 100644 --- a/frontend/src/mocks/storage/Reminder.ts +++ b/frontend/src/mocks/storage/Reminder.ts @@ -1,4 +1,4 @@ -import type { DataResponse } from 'types/DataResponse'; +import type { DataResponse } from 'types/api'; import type { Reminder } from 'types/reminder'; import { getDaysBetween, getParticularDateFromSpecificDay, getDateToString } from 'utils/date'; diff --git a/frontend/src/pages/DictionaryPlantDetail/DictionaryPlantDetail.style.ts b/frontend/src/pages/DictionaryPlantDetail/DictionaryPlantDetail.style.ts index 82c8b315e..e24224f75 100644 --- a/frontend/src/pages/DictionaryPlantDetail/DictionaryPlantDetail.style.ts +++ b/frontend/src/pages/DictionaryPlantDetail/DictionaryPlantDetail.style.ts @@ -6,3 +6,56 @@ export const Main = styled.main` gap: 24px; margin: 0 auto 100px auto; `; + +export const BackButton = styled.button` + display: flex; + align-items: center; + font-size: 2rem; +`; + +export const BottomSheet = styled.div` + position: fixed; + bottom: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + max-width: ${(props) => props.theme.width.pad}; + height: 60px; + padding: 0 32px; + + background-color: ${(props) => props.theme.color.background}; + box-shadow: 0 -1px 1px -1px ${(props) => props.theme.color.subLight}; +`; + +const Button = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 36px; + padding: 0 1rem; + + font-size: 1.5rem; + font-weight: 500; + letter-spacing: 1px; + + border-radius: 4px; + + &:disabled { + cursor: not-allowed; + } +`; + +export const PrimaryButton = styled(Button)` + color: ${({ theme }) => theme.color.background}; + background: ${(props) => props.theme.color.primary}; + + &:disabled { + color: ${(props) => props.theme.color.sub + '40'}; + background: ${(props) => props.theme.color.primary + '40'}; + } +`; diff --git a/frontend/src/pages/DictionaryPlantDetail/index.tsx b/frontend/src/pages/DictionaryPlantDetail/index.tsx index a4bf0087b..225cc46b6 100644 --- a/frontend/src/pages/DictionaryPlantDetail/index.tsx +++ b/frontend/src/pages/DictionaryPlantDetail/index.tsx @@ -1,25 +1,56 @@ -import { useParams } from 'react-router-dom'; +import { generatePath, useNavigate, useParams } from 'react-router-dom'; +import { Header } from 'pages/PetPlantRegister/Form/Form.style'; import Image from 'components/@common/Image'; -import Navbar from 'components/@common/Navbar'; +import SvgFill from 'components/@common/SvgIcons/SvgFill'; import DictionaryPlantContent from 'components/dictionaryPlant/DictionaryPlantContent'; -import { Main } from './DictionaryPlantDetail.style'; +import { BackButton, BottomSheet, Main, PrimaryButton } from './DictionaryPlantDetail.style'; +import useCheckSessionId from 'hooks/queries/auth/useCheckSessionId'; import useDictionaryPlantDetail from 'hooks/queries/dictionaryPlant/useDictionaryPlantDetail'; +import useAddToast from 'hooks/useAddToast'; +import { URL_PATH } from 'constants/index'; +import theme from 'style/theme.style'; const DictionaryPlantDetail = () => { const { id } = useParams(); if (!id) throw new Error('URL에 id가 없습니다.'); + const { isSuccess: isValidSession } = useCheckSessionId(false); + const addToast = useAddToast(); + const dictionaryPlantId = Number(id); const { data: dictionaryPlantDetail } = useDictionaryPlantDetail(dictionaryPlantId); const { image, name } = dictionaryPlantDetail; + const navigate = useNavigate(); + + const goBack = () => { + navigate(-1); + }; + + const goPetPlantRegisterForm = () => { + navigate(generatePath(URL_PATH.petRegisterForm, { id: String(dictionaryPlantId) })); + }; + + const warning = () => { + addToast('info', '로그인 후 등록할 수 있어요 😊'); + }; + return ( <> +
+ + + +
{name}
- + + + 반려 식물로 등록하기 + + ); }; diff --git a/frontend/src/pages/DictionaryPlantSearch/index.tsx b/frontend/src/pages/DictionaryPlantSearch/index.tsx index 967b2e4fb..4c30fa470 100644 --- a/frontend/src/pages/DictionaryPlantSearch/index.tsx +++ b/frontend/src/pages/DictionaryPlantSearch/index.tsx @@ -1,20 +1,24 @@ +import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import Navbar from 'components/@common/Navbar'; import SearchBox from 'components/search/SearchBox'; import SearchResults from 'components/search/SearchResults'; import { Title, Wrapper } from './DictionaryPlantSearch.style'; -import useDictionaryNavigate from 'hooks/useDictionaryNavigate'; +import useDictionaryNavigate from 'hooks/useDictionaryPlantNavigate'; const DictionarySearch = () => { const [params] = useSearchParams(); const search = params.get('search') ?? ''; const { goToProperDictionaryPlantPage, goToDictionaryPlantDetailPage } = useDictionaryNavigate(); + const [searchValue, setSearchValue] = useState(''); return ( <> ` + width: ${(props) => props.width}; + height: ${(props) => props.height}; + border-radius: 4px; + animation: ${skeletonBackground} 1s infinite; +`; export const Wrapper = styled.li` display: flex; @@ -18,7 +37,7 @@ export const Header = styled.div` height: 40px; `; -export const PetPlantImage = styled.img` +export const PetPlantImage = styled(Image)` width: 40px; height: 40px; border-radius: 8px; @@ -48,17 +67,17 @@ export const PetPlantNickname = styled.p` export const DaySince = styled.p` font-size: 1.4rem; font-weight: 500; - color: ${(props) => props.theme.color.sub}; + color: ${(props) => props.theme.color.grayDark}; `; export const PostingDate = styled.p` - width: 72px; + width: max-content; margin-top: 4px; font-size: 1rem; - color: ${(props) => props.theme.color.sub}; + color: ${(props) => props.theme.color.grayDark}; `; -export const Environment = styled.section` +export const GreenBox = styled.section` display: flex; flex-direction: column; row-gap: 4px; @@ -78,6 +97,7 @@ export const EnvironmentItem = styled.p` align-items: center; width: 100%; + height: 24px; font-size: 1.2rem; `; @@ -96,28 +116,55 @@ export const EnvironmentTitle = styled.span` border-radius: 50%; `; +export const ContentArea = styled.div` + width: 100%; + margin-top: 32px; + padding: 4px; + + font-size: 1.6rem; + color: ${(props) => props.theme.color.sub}; +`; + export const TagArea = styled.div` + overflow: scroll; display: flex; - flex-wrap: wrap; - row-gap: 8px; + align-items: center; width: 100%; - margin-top: 24px; + height: 40px; + margin-top: 16px; + + &::-webkit-scrollbar { + display: none; + } +`; + +export const TagRow = styled.div` + display: flex; + column-gap: 10px; + height: 32px; `; export const Tag = styled.div` width: max-content; - margin-right: 8px; + height: 32px; padding: 8px 12px; - font-size: 1.6rem; - font-weight: 700; + font-size: 1.4rem; + font-weight: 600; color: ${(props) => props.theme.color.sub}; - border: solid 1.6px; + border: solid 1.5px; border-radius: 16px; `; +export const TagSkeleton = styled(Tag)` + width: 80px; + height: 33px; + border: solid 1.5px transparent; + animation: ${skeletonBackground} 1s infinite; +`; + export const DictionaryPlantTag = styled(Tag)` border-color: ${({ theme }) => theme.color.primary}; `; @@ -129,10 +176,3 @@ export const WaterCycleTag = styled(Tag)` export const ManageLevelTag = styled(Tag)` border-color: ${({ theme }) => theme.color.grayDark}; `; - -export const ContentArea = styled.div` - width: 100%; - margin-top: 24px; - font-size: 1.6rem; - color: ${(props) => props.theme.color.sub}; -`; diff --git a/frontend/src/pages/GardenPostList/GardenPostItem/GardenPostItemSkeleton.tsx b/frontend/src/pages/GardenPostList/GardenPostItem/GardenPostItemSkeleton.tsx new file mode 100644 index 000000000..5d878ba86 --- /dev/null +++ b/frontend/src/pages/GardenPostList/GardenPostItem/GardenPostItemSkeleton.tsx @@ -0,0 +1,52 @@ +import { + ContentArea, + EnvironmentItem, + GreenBox, + Header, + HeaderContent, + SkeletonItem, + TagArea, + TagRow, + TagSkeleton, + Wrapper, +} from './GardenPostItem.styles'; + +const GardenPostItemSkeleton = () => { + return ( + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default GardenPostItemSkeleton; diff --git a/frontend/src/components/garden/GardenPostItem/index.tsx b/frontend/src/pages/GardenPostList/GardenPostItem/index.tsx similarity index 86% rename from frontend/src/components/garden/GardenPostItem/index.tsx rename to frontend/src/pages/GardenPostList/GardenPostItem/index.tsx index 008e2387f..544f6559e 100644 --- a/frontend/src/components/garden/GardenPostItem/index.tsx +++ b/frontend/src/pages/GardenPostList/GardenPostItem/index.tsx @@ -7,7 +7,7 @@ import { ContentArea, DaySince, DictionaryPlantTag, - Environment, + GreenBox, EnvironmentItem, EnvironmentTitle, Header, @@ -19,6 +19,7 @@ import { TagArea, WaterCycleTag, Wrapper, + TagRow, } from './GardenPostItem.styles'; type GardenPostItemProps = Omit; @@ -47,12 +48,19 @@ const GardenPostItem = ({ {postingDate} + + {content} + - {dictionaryPlantName} - 물 주기: {petPlant.waterCycle}일 - {manageLevel !== '정보없음' && {manageLevel}에게 추천해요} + + {dictionaryPlantName} + 물 주기: {petPlant.waterCycle}일 + {manageLevel !== '정보없음' && ( + {manageLevel}에게 추천해요 + )} + - + {petPlant.wind} - - - {content} - +
); }; diff --git a/frontend/src/pages/GardenPostList/GardenPostList.style.ts b/frontend/src/pages/GardenPostList/GardenPostList.style.ts new file mode 100644 index 000000000..529ab6537 --- /dev/null +++ b/frontend/src/pages/GardenPostList/GardenPostList.style.ts @@ -0,0 +1,56 @@ +import { styled } from 'styled-components'; + +export const Main = styled.main` + position: relative; + width: 100%; + height: 100%; + padding: 8px; +`; + +export const List = styled.ul` + margin-top: 24px; + + & > li + li { + margin-top: 48px; + } +`; + +export const FixedButtonArea = styled.div` + position: fixed; + bottom: 0; + width: 100%; + max-width: ${(props) => props.theme.width.pad}; +`; + +export const FixedButton = styled.button` + position: absolute; + right: 24px; + bottom: 76px; + + display: flex; + align-items: center; + justify-content: center; + + width: 56px; + height: 56px; + + background-color: ${(props) => props.theme.color.primary}; + border-radius: 28px; +`; + +export const Sensor = styled.div` + position: absolute; + bottom: 0; + height: 8px; + margin-bottom: 16px; +`; + +export const Message = styled.p` + display: flex; + justify-content: center; + + margin-top: 32px; + + font-size: 1.4rem; + color: ${(props) => props.theme.color.subLight}; +`; diff --git a/frontend/src/pages/GardenPostList/GardenPostListHeader/GardenPostListHeader.style.ts b/frontend/src/pages/GardenPostList/GardenPostListHeader/GardenPostListHeader.style.ts new file mode 100644 index 000000000..fe4c633fd --- /dev/null +++ b/frontend/src/pages/GardenPostList/GardenPostListHeader/GardenPostListHeader.style.ts @@ -0,0 +1,35 @@ +import { styled } from 'styled-components'; + +export const Wrapper = styled.header` + position: sticky; + z-index: ${(props) => props.theme.zIndex.fixed}; + top: 0; + + display: flex; + flex-direction: column; + + padding: 8px; + + background-color: ${(props) => props.theme.color.background}; + border-bottom: solid 1px ${(props) => props.theme.color.gray}; +`; + +export const FilterArea = styled.div` + display: flex; + align-items: end; + + height: 20px; + margin-top: 8px; + padding-left: 16px; +`; + +export const FilterTag = styled.p` + display: flex; + column-gap: 4px; + align-items: center; + font-size: 1.4rem; +`; + +export const DeleteFilterButton = styled.button` + font-size: 1.2rem; +`; diff --git a/frontend/src/pages/GardenPostList/GardenPostListHeader/index.tsx b/frontend/src/pages/GardenPostList/GardenPostListHeader/index.tsx new file mode 100644 index 000000000..8bb31c7d1 --- /dev/null +++ b/frontend/src/pages/GardenPostList/GardenPostListHeader/index.tsx @@ -0,0 +1,47 @@ +import type { DictionaryPlantNameSearchResult } from 'types/dictionaryPlant'; +import { useState } from 'react'; +import SearchBox from 'components/search/SearchBox'; +import { DeleteFilterButton, FilterTag, Wrapper, FilterArea } from './GardenPostListHeader.style'; + +interface GardenPostListHeaderProps { + selectedDictionaryPlant: DictionaryPlantNameSearchResult | null; + select: (searchResult: DictionaryPlantNameSearchResult) => void; + clear: () => void; +} + +const GardenPostListHeader = ({ + selectedDictionaryPlant, + select, + clear, +}: GardenPostListHeaderProps) => { + const [searchValue, setSearchValue] = useState(''); + + const onClickDelete = () => { + setSearchValue(''); + clear(); + }; + + return ( + + + + {selectedDictionaryPlant && ( + + {selectedDictionaryPlant.name} + + ✕ + + + )} + + + ); +}; + +export default GardenPostListHeader; diff --git a/frontend/src/pages/GardenPostList/index.tsx b/frontend/src/pages/GardenPostList/index.tsx new file mode 100644 index 000000000..d75fee4b5 --- /dev/null +++ b/frontend/src/pages/GardenPostList/index.tsx @@ -0,0 +1,80 @@ +import type { DictionaryPlantNameSearchResult } from 'types/dictionaryPlant'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { useRecoilState } from 'recoil'; +import Navbar from 'components/@common/Navbar'; +import SvgStroke from 'components/@common/SvgIcons/SvgStroke'; +import { FixedButtonArea, FixedButton, List, Main, Message, Sensor } from './GardenPostList.style'; +import selectedDictionaryPlantAtom from 'store/atoms/garden'; +import useIntersectionRef from 'hooks/useIntersectionRef'; +import { URL_PATH } from 'constants/index'; +import useGardenPostList from '../../hooks/queries/garden/useGardenPostList'; +import GardenPostItem from './GardenPostItem'; +import GardenPostItemSkeleton from './GardenPostItem/GardenPostItemSkeleton'; +import GardenPostListHeader from './GardenPostListHeader'; + +const SKELETON_LENGTH = 20; + +const GardenPostList = () => { + const navigate = useNavigate(); + const [selectedDictionaryPlant, setSelectedDictionaryPlant] = useRecoilState( + selectedDictionaryPlantAtom + ); + + const { + data: gardenPostList, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useGardenPostList(selectedDictionaryPlant ? selectedDictionaryPlant.id : null); + + const intersectionRef = useIntersectionRef(fetchNextPage); + + const select = (searchResult: DictionaryPlantNameSearchResult) => { + setSelectedDictionaryPlant(searchResult); + }; + + const clear = () => { + setSelectedDictionaryPlant(null); + }; + + const goGardenRegisterPick = () => { + navigate(URL_PATH.gardenRegisterPick); + }; + + useEffect(() => { + window.scrollTo(0, 0); + }, [selectedDictionaryPlant]); + + return ( + <> + +
+ + {gardenPostList?.map((gardenPost) => ( + + ))} + {(isLoading || isFetchingNextPage) && + Array(SKELETON_LENGTH) + .fill(null) + .map((_, index) => )} + + {!isFetchingNextPage && } + {!hasNextPage && 마지막이에요 😊} +
+ + + + + + + + ); +}; + +export default GardenPostList; diff --git a/frontend/src/pages/Main/index.tsx b/frontend/src/pages/Main/index.tsx index 75a192254..da17f5cc4 100644 --- a/frontend/src/pages/Main/index.tsx +++ b/frontend/src/pages/Main/index.tsx @@ -1,12 +1,14 @@ +import { useState } from 'react'; import Navbar from 'components/@common/Navbar'; import SearchBox from 'components/search/SearchBox'; import { LogoMessage, SearchBoxArea, SearchMessage, Wrapper } from './Main.style'; -import useDictionaryNavigate from 'hooks/useDictionaryNavigate'; +import useDictionaryNavigate from 'hooks/useDictionaryPlantNavigate'; import LogoSvg from 'assets/logo.svg'; import LogoWebp from 'assets/logo.webp'; const Main = () => { const { goToProperDictionaryPlantPage, goToDictionaryPlantDetailPage } = useDictionaryNavigate(); + const [searchValue, setSearchValue] = useState(''); return ( <> @@ -19,6 +21,8 @@ const Main = () => { 피움에 등록된 식물을 검색해 보세요! props.theme.color.background}; + background-color: ${(props) => props.theme.color.background + '44'}; + backdrop-filter: blur(4px); box-shadow: 0 2px 2px -2px ${(props) => props.theme.color.gray}; `; diff --git a/frontend/src/pages/PetPlantRegister/Search/index.tsx b/frontend/src/pages/PetPlantRegister/Search/index.tsx index aec509c70..0c0c5e408 100644 --- a/frontend/src/pages/PetPlantRegister/Search/index.tsx +++ b/frontend/src/pages/PetPlantRegister/Search/index.tsx @@ -1,3 +1,5 @@ +import type { DictionaryPlantNameSearchResult } from 'types/dictionaryPlant'; +import { useState } from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; import Navbar from 'components/@common/Navbar'; import SearchBox from 'components/search/SearchBox'; @@ -9,7 +11,9 @@ const PetPlantRegisterSearch = () => { const navigate = useNavigate(); useCheckSessionId(); - const navigateForm = (id: number) => { + const [searchValue, setSearchValue] = useState(''); + + const navigateForm = ({ id }: DictionaryPlantNameSearchResult) => { navigate(generatePath(URL_PATH.petRegisterForm, { id: String(id) })); }; @@ -18,7 +22,11 @@ const PetPlantRegisterSearch = () => { 어떤 식물을 키우시나요? - + diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 5a8a1e621..1dafbad36 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -3,6 +3,7 @@ import { createBrowserRouter } from 'react-router-dom'; import DictionaryPlantDetail from 'pages/DictionaryPlantDetail'; import DictionaryPlantSearch from 'pages/DictionaryPlantSearch'; import NotFound from 'pages/Error/NotFound'; +import GardenPostList from 'pages/GardenPostList'; import GardenRegisterForm from 'pages/GardenRegister/Form'; import GardenRegisterPick from 'pages/GardenRegister/Pick'; import Main from 'pages/Main'; @@ -69,6 +70,10 @@ const router = createBrowserRouter([ path: URL_PATH.timeline, element: , }, + { + path: URL_PATH.garden, + element: , + }, { path: URL_PATH.login, element: , diff --git a/frontend/src/store/atoms/garden.ts b/frontend/src/store/atoms/garden.ts new file mode 100644 index 000000000..46b1de46d --- /dev/null +++ b/frontend/src/store/atoms/garden.ts @@ -0,0 +1,9 @@ +import type { DictionaryPlantNameSearchResult } from 'types/dictionaryPlant'; +import { atom } from 'recoil'; + +const selectedDictionaryPlantAtom = atom({ + key: 'selectedDictionaryPlant', + default: null, +}); + +export default selectedDictionaryPlantAtom; diff --git a/frontend/src/style/theme.style.ts b/frontend/src/style/theme.style.ts index aa898f5f6..8e0836978 100644 --- a/frontend/src/style/theme.style.ts +++ b/frontend/src/style/theme.style.ts @@ -28,7 +28,8 @@ const width = { } as const; const zIndex = { - dropdown: 1000, + dropdownBackdrop: 1000, + dropdown: 1010, sticky: 1020, fixed: 1030, modalBackdrop: 1040, diff --git a/frontend/src/types/DataResponse.ts b/frontend/src/types/api.ts similarity index 72% rename from frontend/src/types/DataResponse.ts rename to frontend/src/types/api.ts index 765d971dc..b0804098e 100644 --- a/frontend/src/types/DataResponse.ts +++ b/frontend/src/types/api.ts @@ -4,6 +4,13 @@ export interface DataResponse { data: T; } +export interface PageDataResponse extends DataResponse { + page: number; + size: number; + elementSize: number; + hasNext: boolean; +} + export interface MutationProps { mutationCallback?: (data: T, variable: V) => void; successCallback?: (data: T, variable: V) => void; diff --git a/frontend/src/types/history.ts b/frontend/src/types/history.ts index 17c1574e8..468c018d9 100644 --- a/frontend/src/types/history.ts +++ b/frontend/src/types/history.ts @@ -1,15 +1,7 @@ import { NO_PREVIOUS_VALUE } from 'constants/index'; -import type { DataResponse } from './DataResponse'; import type { DateFormat } from './date'; import type { PetPlantDetails } from './petPlant'; -export interface HistoryResponse extends DataResponse { - page: number; - size: number; - elementSize: number; - hasNext: boolean; -} - export interface HistoryItem { type: HistoryType; date: DateFormat;