diff --git a/client/src/app/wiki/layout.tsx b/client/src/app/wiki/layout.tsx index 3183fee..3f89e4d 100644 --- a/client/src/app/wiki/layout.tsx +++ b/client/src/app/wiki/layout.tsx @@ -1,25 +1,33 @@ +"use client"; import { Paper, Container } from "@mui/material"; import WikiAppbar from "@/components/wiki/WikiAppbar"; -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; +import WikiPageContext from "@/store/wiki/WikiPageContext"; +import WikiSearchDrawer from "@/components/wiki/searchDrawer/WikiSearchDrawer"; const layout = ({ children }: { children: ReactNode }) => { + const [isSearching, setIsSearching] = useState(false); + return ( - - - - - {children} - - - + + + + + + + {children} + + + + ); }; diff --git a/client/src/app/wiki/page.tsx b/client/src/app/wiki/page.tsx index c0d19e2..a74f28a 100644 --- a/client/src/app/wiki/page.tsx +++ b/client/src/app/wiki/page.tsx @@ -1,4 +1,4 @@ -import AlcoholList from "@/components/wiki/AlcoholList"; +import AlcoholPagination from "@/components/wiki/AlcoholPagination"; import WikiAlcoholSelector from "@/components/wiki/WikiAlcoholSelector"; import { Stack } from "@mui/material"; import SectionHeading from "@/components/SectionHeading"; @@ -14,7 +14,7 @@ const WikiPage = async () => { subTitle={"투파이아들이 쓴 리뷰를 확인할 수 있어요!"} /> - + diff --git a/client/src/components/SearchHistory.tsx b/client/src/components/SearchHistory.tsx new file mode 100644 index 0000000..873ce5b --- /dev/null +++ b/client/src/components/SearchHistory.tsx @@ -0,0 +1,72 @@ +import { Button, Stack, StackProps, Typography } from "@mui/material"; +import { useCallback, useState } from "react"; +import XIcon from "@/assets/icons/XIcon.svg"; + +interface SearchHistoryProps extends Omit { + storageKey: string; + onClick: () => void; +} + +const SearchHistory = ({ storageKey, onClick }: SearchHistoryProps) => { + const getItems = useCallback(() => { + return JSON.parse(localStorage.getItem(storageKey) ?? "[]") as string[]; + }, [storageKey]); + + const [searchHistory, setSearchHistory] = useState(getItems()); + + const removeAll = useCallback(() => { + localStorage.setItem(storageKey, "[]"); + setSearchHistory(getItems()); + }, [storageKey]); + + const removeByKeyword = useCallback( + (keyword: string) => { + const filteredHistory = searchHistory.filter( + (prevKeyword) => prevKeyword !== keyword + ); + localStorage.setItem(storageKey, JSON.stringify(filteredHistory)); + setSearchHistory(getItems()); + }, + [storageKey] + ); + + return searchHistory.length > 0 ? ( + <> + + + 최근 검색어 + + + + + {searchHistory.map((keyword) => ( + + {keyword} + + + ))} + + + ) : ( + <> + ); +}; + +export default SearchHistory; diff --git a/client/src/components/newpost/SearchAlcoholInput.tsx b/client/src/components/newpost/SearchAlcoholInput.tsx index 1aba2bf..5884c5c 100644 --- a/client/src/components/newpost/SearchAlcoholInput.tsx +++ b/client/src/components/newpost/SearchAlcoholInput.tsx @@ -35,7 +35,6 @@ const SearchAlcoholInput = ({ setAlcoholNo }: SearchAlcoholInputInterface) => { const [selectedAlcohol, setSelectedAlcohol] = useState(); - useEffect(() => { setSearchKeyword(selectedAlcohol?.alcoholName ?? ""); setAlcoholNo(selectedAlcohol?.alcoholNo); @@ -48,6 +47,9 @@ const SearchAlcoholInput = ({ setAlcoholNo }: SearchAlcoholInputInterface) => { name="positionInfo" size="small" InputProps={{ + sx: { + borderRadius: 12, + }, startAdornment: ( @@ -109,8 +111,8 @@ const SearchAlcoholInput = ({ setAlcoholNo }: SearchAlcoholInputInterface) => { const WrapperStyle = { width: "calc(100% - 32px)", minHeight: "50px", - maxHeight:'142px', - overflowY:'auto', + maxHeight: "142px", + overflowY: "auto", backgroundColor: "#F5F5F5", border: "1px solid #E6E6E6", borderRadius: 1.5, diff --git a/client/src/components/search/SearchArea.tsx b/client/src/components/search/SearchArea.tsx index 1b7f445..7f6f7fe 100644 --- a/client/src/components/search/SearchArea.tsx +++ b/client/src/components/search/SearchArea.tsx @@ -1,6 +1,6 @@ "use client"; -import { Box, CircularProgress, Paper, TextField } from "@mui/material"; -import React, { useState, useMemo, Suspense } from "react"; +import { Paper, TextField } from "@mui/material"; +import React, { useState, useMemo } from "react"; import PostCardList from "@/components/post/PostCardList"; import { AugmentedGetPostListResponse } from "@/queries/post/useGetPostListInfiniteQuery"; import useDebounce from "@/hooks/useDebounce"; diff --git a/client/src/components/wiki/AlcoholList.tsx b/client/src/components/wiki/AlcoholList.tsx index 56c66b3..298a1b9 100644 --- a/client/src/components/wiki/AlcoholList.tsx +++ b/client/src/components/wiki/AlcoholList.tsx @@ -1,40 +1,29 @@ "use client"; import AlcoholNameTag from "@/components/wiki/AlcoholNameTag"; -import useGetAlcoholListQuery from "@/queries/alcohol/useGetAlcoholListQuery"; -import { Box, Pagination, Skeleton, Stack } from "@mui/material"; +import { AlcoholDetailInterface } from "@/types/alcohol/AlcoholInterface"; +import { Typography } from "@mui/material"; import { memo } from "react"; -const AlcoholList = () => { - const { data: alcohols } = useGetAlcoholListQuery(); +const AlcoholList = ({ + data: alcohols, +}: { + data: AlcoholDetailInterface[]; +}) => { return ( - - - {alcohols ? ( - alcohols.list.map((alcohol) => ( - - )) - ) : ( - - )} - - - + <> + {alcohols?.length > 0 ? ( + alcohols.map((alcohol) => ( + + )) + ) : ( + 검색 결과가 없어요 + )} + ); }; +export default memo(AlcoholList); -const AlcoholListSkeleton = memo(() => { - return Array.from(new Array(5)).map(() => ( - - )); -}); - -export default AlcoholList; diff --git a/client/src/components/wiki/AlcoholListSkeleton.tsx b/client/src/components/wiki/AlcoholListSkeleton.tsx new file mode 100644 index 0000000..3cbf74b --- /dev/null +++ b/client/src/components/wiki/AlcoholListSkeleton.tsx @@ -0,0 +1,31 @@ +import { memo } from "react"; +import { Skeleton } from "@mui/material"; + +import useSkeletonTimer from "@/hooks/useSkeletonTimer"; + +interface AlcoholListSkeletonInterface { + size?: number; + disableTimer?: boolean; +} + +const AlcoholListSkeleton = memo( + ({ size = 5, disableTimer }: AlcoholListSkeletonInterface) => { + const isOver200ms = !!disableTimer ? true : useSkeletonTimer(); + + return isOver200ms ? ( + Array.from(new Array(size)).map((_e, i) => ( + + )) + ) : ( + <> + ); + } +); + +export default AlcoholListSkeleton; diff --git a/client/src/components/wiki/AlcoholPagination.tsx b/client/src/components/wiki/AlcoholPagination.tsx new file mode 100644 index 0000000..dbeae6f --- /dev/null +++ b/client/src/components/wiki/AlcoholPagination.tsx @@ -0,0 +1,24 @@ +"use client"; +import useGetAlcoholListQuery from "@/queries/alcohol/useGetAlcoholListQuery"; +import AlcoholList from "@/components/wiki/AlcoholList"; +import { Pagination, Stack } from "@mui/material"; +import AlcoholListSkeleton from "@/components/wiki/AlcoholListSkeleton"; + +const AlcoholPagenation = () => { + const { data: alcohols, isSuccess } = useGetAlcoholListQuery(); + + return ( + + + {isSuccess ? ( + + ) : ( + + )} + + + + ); +}; + +export default AlcoholPagenation; diff --git a/client/src/components/wiki/WikiAlcoholSelector.tsx b/client/src/components/wiki/WikiAlcoholSelector.tsx index 2af1e9a..79aa30a 100644 --- a/client/src/components/wiki/WikiAlcoholSelector.tsx +++ b/client/src/components/wiki/WikiAlcoholSelector.tsx @@ -9,28 +9,30 @@ import TraditionalAlcoholIcon from "@/assets/icons/Alcohol/TraditionalAlcoholIco import SakeIcon from "@/assets/icons/Alcohol/SakeIcon.svg"; const WikiAlcoholSelector = () => { - - const btnList =useMemo(()=>[ - { title: "포도주", iconComponent: }, - { title: "위스키", iconComponent: }, - { title: "증류주", iconComponent: }, - { title: "우리술", iconComponent: }, - { title: "사케", iconComponent: }, - ],[]) + const btnList = useMemo( + () => [ + { title: "포도주", iconComponent: }, + { title: "위스키", iconComponent: }, + { title: "증류주", iconComponent: }, + { title: "우리술", iconComponent: }, + { title: "사케", iconComponent: }, + ], + [] + ); const [selectedAlcohol, setSelectedAlcohol] = useState(btnList[0].title); - const clickHandler = useCallback((title:string)=>{ - setSelectedAlcohol(title) - },[]) + const clickHandler = useCallback((title: string) => { + setSelectedAlcohol(title); + }, []); return ( - + {btnList.map((btnInfo) => ( clickHandler(btnInfo.title)} + onClick={() => clickHandler(btnInfo.title)} {...btnInfo} /> ))} diff --git a/client/src/components/wiki/WikiAppbar.tsx b/client/src/components/wiki/WikiAppbar.tsx index 94c823e..2e7b6f2 100644 --- a/client/src/components/wiki/WikiAppbar.tsx +++ b/client/src/components/wiki/WikiAppbar.tsx @@ -1,15 +1,18 @@ -'use client' +"use client"; import CustomAppbar from "@/components/CustomAppbar"; import SearchIcon from "@/assets/icons/SearchIcon.svg"; +import { memo, useContext } from "react"; +import WikiPageContext from "@/store/wiki/WikiPageContext"; const WikiAppbar = () => { + const { setIsSearching } = useContext(WikiPageContext); return ( } - onClickButton={() => console.log("눌림")} + onClickButton={() => setIsSearching(true)} /> ); }; -export default WikiAppbar; +export default memo(WikiAppbar); diff --git a/client/src/components/wiki/searchDrawer/WikiSearchDrawer.tsx b/client/src/components/wiki/searchDrawer/WikiSearchDrawer.tsx new file mode 100644 index 0000000..2538faf --- /dev/null +++ b/client/src/components/wiki/searchDrawer/WikiSearchDrawer.tsx @@ -0,0 +1,47 @@ +import { SwipeableDrawer, Stack, styled, Box } from "@mui/material"; +import { useContext } from "react"; +import WikiPageContext from "@/store/wiki/WikiPageContext"; +import WikiSerachArea from "@/components/wiki/searchDrawer/WikiSerachArea"; + +const WikiSearchDrawer = () => { + const { isSearching, setIsSearching } = useContext(WikiPageContext); + + const pullerBleed = 24; + return ( + setIsSearching(true)} + onClose={() => setIsSearching(false)} + anchor="bottom" + disableSwipeToOpen + PaperProps={{ + sx: { + p: 2, + borderTopLeftRadius: pullerBleed, + borderTopRightRadius: pullerBleed, + overFlow: "hidden", + }, + }} + ModalProps={{ + keepMounted: false, + }} + > + + + + + + ); +}; + +export default WikiSearchDrawer; + +const Puller = styled(Box)(() => ({ + width: 56, + height: 4, + backgroundColor: "#F6EAFB", + borderRadius: 3, + position: "absolute", + top: 8, + left: "calc(50% - 28px)", +})); diff --git a/client/src/components/wiki/searchDrawer/WikiSerachArea.tsx b/client/src/components/wiki/searchDrawer/WikiSerachArea.tsx new file mode 100644 index 0000000..7c517fc --- /dev/null +++ b/client/src/components/wiki/searchDrawer/WikiSerachArea.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from "react"; +import useDebounce from "@/hooks/useDebounce"; +import InputSearchIcon from "@/assets/icons/InputSearchIcon.svg"; +import { Stack, TextField } from "@mui/material"; +import useGetAlcoholListQuery from "@/queries/alcohol/useGetAlcoholListQuery"; +import AlcoholList from "@/components/wiki/AlcoholList"; +import AlcoholListSkeleton from "../AlcoholListSkeleton"; +import SearchHistory from "@/components/SearchHistory"; +import { ALCOHOL_SEARCH_HISTORY } from "@/const/localstorageKey"; + +const WikiSerachArea = () => { + const [searchKeyword, setSearchKeyword] = useState(""); + const debouncedValue = useDebounce(searchKeyword, 300); + const { data: alcohols, isSuccess } = useGetAlcoholListQuery(debouncedValue); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( + <> + setSearchKeyword(target.value)} + InputProps={{ + endAdornment: , + sx: { + borderRadius: "12px", + }, + }} + /> + + {searchKeyword ? ( + // 입력중인 경우 + <> + {isSuccess ? ( + + ) : ( + + )} + + ) : ( + // 입력이 없는경우 검색기록 표출 + console.log("눌림")} + storageKey={ALCOHOL_SEARCH_HISTORY} + /> + )} + + + ); +}; + +export default WikiSerachArea; diff --git a/client/src/const/localstorageKey.ts b/client/src/const/localstorageKey.ts new file mode 100644 index 0000000..26b6387 --- /dev/null +++ b/client/src/const/localstorageKey.ts @@ -0,0 +1 @@ +export const ALCOHOL_SEARCH_HISTORY ='alcohol-search-history' as const \ No newline at end of file diff --git a/client/src/hooks/useSkeletonTimer.ts b/client/src/hooks/useSkeletonTimer.ts new file mode 100644 index 0000000..d444958 --- /dev/null +++ b/client/src/hooks/useSkeletonTimer.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +/** + * 시간을 인자로 받아 해당 시간이 지난 후 true를 리턴, 시간이 지나지 않았을 경우 false를 리턴 + * @param time ms 단위, 기본값은 200ms + * @returns 입력받은 시간이 지났는지 여부 + */ +const useSkeletonTimer = (time: number = 200) => { + const [isTimePassed, setTimer] = useState(false); + + useEffect(() => { + const timerId = setTimeout(() => { + setTimer(true); + }, time); + return () => { + clearTimeout(timerId); + }; + }, []); + return isTimePassed; +}; + +export default useSkeletonTimer; diff --git a/client/src/queries/alcohol/useGetAlcoholListQuery.tsx b/client/src/queries/alcohol/useGetAlcoholListQuery.tsx index b1f5e3d..b2556e5 100644 --- a/client/src/queries/alcohol/useGetAlcoholListQuery.tsx +++ b/client/src/queries/alcohol/useGetAlcoholListQuery.tsx @@ -11,6 +11,9 @@ const useGetAlcoholListQuery = (keyword?: string) => { }; export const getAlcoholListByKeyword = async (keyword?: string) => { + if (keyword === "") { + return { list: [], totalCount: 0 }; + } const { data } = await axios.get<{ list: AlcoholDetailInterface[]; totalCount: number; diff --git a/client/src/store/wiki/WikiPageContext.ts b/client/src/store/wiki/WikiPageContext.ts new file mode 100644 index 0000000..b6e5c83 --- /dev/null +++ b/client/src/store/wiki/WikiPageContext.ts @@ -0,0 +1,13 @@ +import { Dispatch, SetStateAction, createContext } from "react"; + +interface WikiPageContextInterface { + isSearching: boolean; + setIsSearching: Dispatch>; +} + +const WikiPageContext = createContext({ + isSearching: true, + setIsSearching: () => {}, +}); + +export default WikiPageContext;