diff --git a/.pnp.cjs b/.pnp.cjs index a328e47e..b5be13c9 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -36,7 +36,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ "fallbackExclusionList": [\ ["@monorepo/chooz", ["workspace:apps/chooz"]],\ - ["@monorepo/hooks", ["workspace:packages/hooks"]],\ + ["@monorepo/hooks", ["virtual:040d27df013de8c2443d97cd34785bd6338b0d64a4a625937768b1a38353fca7b8404d53a1248b5c67570c2a031bc43d100149c15012f4485eb41e15d763240d#workspace:packages/hooks", "workspace:packages/hooks"]],\ ["@monorepo/jurumarble", ["workspace:apps/jurumarble"]],\ ["@monorepo/ui", ["workspace:packages/ui"]],\ ["monorepo", ["workspace:."]]\ @@ -4065,7 +4065,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageLocation": "./apps/chooz/",\ "packageDependencies": [\ ["@monorepo/chooz", "workspace:apps/chooz"],\ - ["@monorepo/hooks", "workspace:packages/hooks"],\ + ["@monorepo/hooks", "virtual:040d27df013de8c2443d97cd34785bd6338b0d64a4a625937768b1a38353fca7b8404d53a1248b5c67570c2a031bc43d100149c15012f4485eb41e15d763240d#workspace:packages/hooks"],\ ["@monorepo/ui", "workspace:packages/ui"],\ ["@next/font", "npm:13.4.19"],\ ["@svgr/cli", "npm:7.0.0"],\ @@ -4094,13 +4094,27 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@monorepo/hooks", [\ + ["virtual:040d27df013de8c2443d97cd34785bd6338b0d64a4a625937768b1a38353fca7b8404d53a1248b5c67570c2a031bc43d100149c15012f4485eb41e15d763240d#workspace:packages/hooks", {\ + "packageLocation": "./.yarn/__virtual__/@monorepo-hooks-virtual-9a9d2697d3/1/packages/hooks/",\ + "packageDependencies": [\ + ["@monorepo/hooks", "virtual:040d27df013de8c2443d97cd34785bd6338b0d64a4a625937768b1a38353fca7b8404d53a1248b5c67570c2a031bc43d100149c15012f4485eb41e15d763240d#workspace:packages/hooks"],\ + ["@types/node", "npm:20.5.7"],\ + ["@types/react", "npm:18.2.21"],\ + ["react", "npm:18.2.0"],\ + ["typescript", "patch:typescript@npm%3A4.9.3#~builtin::version=4.9.3&hash=d73830"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:packages/hooks", {\ "packageLocation": "./packages/hooks/",\ "packageDependencies": [\ ["@monorepo/hooks", "workspace:packages/hooks"],\ ["@types/node", "npm:20.5.7"],\ ["@types/react", "npm:18.2.21"],\ - ["react", "npm:18.2.0"],\ ["typescript", "patch:typescript@npm%3A4.9.3#~builtin::version=4.9.3&hash=d73830"]\ ],\ "linkType": "SOFT"\ @@ -4112,7 +4126,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["@monorepo/jurumarble", "workspace:apps/jurumarble"],\ ["@emotion/is-prop-valid", "npm:1.2.1"],\ - ["@monorepo/hooks", "workspace:packages/hooks"],\ + ["@monorepo/hooks", "virtual:040d27df013de8c2443d97cd34785bd6338b0d64a4a625937768b1a38353fca7b8404d53a1248b5c67570c2a031bc43d100149c15012f4485eb41e15d763240d#workspace:packages/hooks"],\ ["@svgr/cli", "npm:8.1.0"],\ ["@svgr/core", "npm:8.1.0"],\ ["@svgr/plugin-jsx", "virtual:f6fc21e1196a5e36ccf7110701b6043e0b913ea4ae1d1deb9368d1ebef4cb8924deb4e8e07aafc9678d844b070003814a4413e37134a210b07db139914fa76a4#npm:8.1.0"],\ @@ -4143,7 +4157,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageLocation": "./packages/ui/",\ "packageDependencies": [\ ["@monorepo/ui", "workspace:packages/ui"],\ - ["@monorepo/hooks", "workspace:packages/hooks"],\ + ["@monorepo/hooks", "virtual:040d27df013de8c2443d97cd34785bd6338b0d64a4a625937768b1a38353fca7b8404d53a1248b5c67570c2a031bc43d100149c15012f4485eb41e15d763240d#workspace:packages/hooks"],\ ["@types/node", "npm:20.5.7"],\ ["@types/react", "npm:18.2.21"],\ ["@types/styled-components", "npm:5.1.26"],\ diff --git a/apps/jurumarble/next.config.js b/apps/jurumarble/next.config.js index ac3ab48f..d4836953 100644 --- a/apps/jurumarble/next.config.js +++ b/apps/jurumarble/next.config.js @@ -5,6 +5,9 @@ const nextConfig = { styledComponents: true, }, transpilePackages: ["@monorepo/ui, @monorepo/hooks"], + images: { + domains: ["shopping-phinf.pstatic.net"], + }, }; module.exports = nextConfig; diff --git a/apps/jurumarble/src/app/search/components/ChipContainer.tsx b/apps/jurumarble/src/app/search/components/ChipContainer.tsx new file mode 100644 index 00000000..1ff0a1cb --- /dev/null +++ b/apps/jurumarble/src/app/search/components/ChipContainer.tsx @@ -0,0 +1,66 @@ +import { UseMutateFunction } from "@tanstack/react-query"; +import Chip from "components/Chip"; +import React from "react"; +import SvgIcBookmarkActive from "src/assets/icons/components/IcBookmarkActive"; +import SvgIcBookmark from "src/assets/icons/components/IcBookmark"; +import styled from "styled-components"; + +interface Props { + title: string; + date: string; + region: string; + mutateBookMark: UseMutateFunction; + isBookmark: boolean; +} + +const ChipContainer = ({ date, title, region, mutateBookMark, isBookmark }: Props) => { + return ( + <> + + + {region && {region}} + 122명이 즐겼어요 + + + {isBookmark ? ( + mutateBookMark()} /> + ) : ( + mutateBookMark()} /> + )} + + + + {title} + {/* {date.slice(0, 10)} */} + + {date} + + ); +}; + +const TagRow = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; +`; + +const TitleRow = styled.div` + display: flex; + margin-top: 20px; + ${({ theme }) => theme.typography.body01}; +`; + +const DateText = styled.div` + color: ${({ theme }) => theme.colors.black_04}; + ${({ theme }) => theme.typography.body_long03} + text-align: right; + margin: 8px 0 20px; +`; + +const FlexRow = styled.div` + display: flex; + gap: 4px; +`; + +export default ChipContainer; diff --git a/apps/jurumarble/src/app/search/components/DrinkList.tsx b/apps/jurumarble/src/app/search/components/DrinkList.tsx new file mode 100644 index 00000000..37cbcd2b --- /dev/null +++ b/apps/jurumarble/src/app/search/components/DrinkList.tsx @@ -0,0 +1,58 @@ +import { Button } from "components/button"; +import DrinkItem from "components/DrinkItem"; +import { SortType } from "src/types/common"; +import styled, { css } from "styled-components"; +import useSearchDrinkService from "../services/useDrinkService"; + +interface Props { + searchText: string; + sortOption: string; + regionOption: string; + isSelectedTab: boolean; +} + +function DrinkList({ searchText, sortOption, regionOption, isSelectedTab }: Props) { + const { drinkList, fetchNextPage, hasNextPage } = useSearchDrinkService({ + page: 0, + size: 3, + keyword: searchText, + region: regionOption, + sortBy: sortOption as SortType, + }); + + if (!drinkList) { + return <>; + } + + const onClickFetchNextPage = () => { + hasNextPage && fetchNextPage(); + }; + + return ( + + {drinkList.map((drinkInfo) => ( + + ))} + {!isSelectedTab && ( + + 우리술 정보 더보기 + + )} + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 24px; +`; + +const MoreButton = styled(Button)` + ${({ theme }) => css` + ${theme.typography.body01} + margin: 24px 0 40px 0; + `}; +`; +export default DrinkList; diff --git a/apps/jurumarble/src/app/search/components/DrinkVoteItem.tsx b/apps/jurumarble/src/app/search/components/DrinkVoteItem.tsx new file mode 100644 index 00000000..01387d57 --- /dev/null +++ b/apps/jurumarble/src/app/search/components/DrinkVoteItem.tsx @@ -0,0 +1,51 @@ +import { Vote } from "lib/apis/vote"; +import Path from "lib/Path"; +import { useRouter } from "next/navigation"; +import useBookmarkService from "services/useBookmarkService"; +import styled from "styled-components"; +import ChipContainer from "./ChipContainer"; +import VoteDescription from "./VoteDescription"; + +interface Props { + voteDrink: Vote; +} +/** + * + * @Todo 타입 더 깔끔하게 정의 필요 + */ +function DrinkVoteItem({ voteDrink }: Props) { + const { voteId, region, title, imageA, imageB } = voteDrink; + + const { mutateBookMark, bookMarkCheckQuery } = useBookmarkService(voteId); + + const { data: bookmarkCheck } = bookMarkCheckQuery; + + const isBookmark = bookmarkCheck?.bookmarked || false; + + const router = useRouter(); + const onClickDrinkVoteItem = () => { + router.push(`${Path.VOTE_DETAIL_PAGE}/${voteId}`); + }; + + return ( + + + + + ); +} + +const Container = styled.button` + border-radius: 10px; + border: 1px solid ${({ theme }) => theme.colors.line_02}; + box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.08), 0px 10px 25px 0px rgba(0, 0, 0, 0.06); + padding: 20px; +`; + +export default DrinkVoteItem; diff --git a/apps/jurumarble/src/app/search/components/DrinkVoteList.tsx b/apps/jurumarble/src/app/search/components/DrinkVoteList.tsx new file mode 100644 index 00000000..4f105da1 --- /dev/null +++ b/apps/jurumarble/src/app/search/components/DrinkVoteList.tsx @@ -0,0 +1,58 @@ +import { Button } from "components/button"; +import styled, { css } from "styled-components"; +import useVoteDrinkService from "../services/useVoteDrinkService"; +import DrinkVoteItem from "./DrinkVoteItem"; + +interface Props { + searchText: string; + sortOption: string; + regionOption: string; + isSelectedTab: boolean; +} + +function DrinkVoteList({ searchText, sortOption, regionOption, isSelectedTab }: Props) { + const { voteDrinkList, fetchNextPage, hasNextPage } = useVoteDrinkService({ + page: 0, + size: 3, + keyword: searchText, + region: regionOption, + sortBy: sortOption, + }); + + if (!voteDrinkList) { + return <>; + } + + const onClickFetchNextPage = () => { + hasNextPage && fetchNextPage(); + }; + + return ( + + {voteDrinkList.map((voteDrink, index) => ( + + ))} + {!isSelectedTab && ( + + 우리술 투표 더보기 + + )} + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + margin-top: 24px; + gap: 8px; +`; + +const MoreButton = styled(Button)` + ${({ theme }) => css` + ${theme.typography.body01} + margin: 24px 0 40px 0; + `}; +`; + +export default DrinkVoteList; diff --git a/apps/jurumarble/src/app/search/components/RegionSmallSelect.tsx b/apps/jurumarble/src/app/search/components/RegionSmallSelect.tsx new file mode 100644 index 00000000..6e2972aa --- /dev/null +++ b/apps/jurumarble/src/app/search/components/RegionSmallSelect.tsx @@ -0,0 +1,62 @@ +import { REGION_LIST, SORT_LIST } from "lib/constants"; +import styled, { css } from "styled-components"; +import { useToggle } from "@monorepo/hooks"; +import SvgIcExpandMore from "src/assets/icons/components/IcExpandMore"; +import { Select } from "components/selectBox"; + +interface Props { + defaultOption: string; + onChangeSortOption: (id: string) => void; +} + +REGION_LIST.unshift({ value: "", label: "지역" }); + +function RegionSmallSelect({ defaultOption, onChangeSortOption }: Props) { + const [isOpen, onToggleOpen] = useToggle(); + + return ( + + + + ); +} + +const SelectStyled = styled.span<{ isOpen: boolean }>` + ${({ theme, isOpen }) => css` + ${theme.typography.button01}; + color: ${theme.colors.black_03}; + width: 80px; + height: 40px; + .selected-label { + border: 1px solid ${theme.colors.line_01}; + border-radius: 8px; + padding: 10px 12px; + width: 96px; + } + svg { + ${isOpen && "transform: rotateX( 180deg )"} + } + `} + #select-list { + width: 100px; + height: 200px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 8px 0; + } + #indicator { + display: flex; + } +`; + +export default RegionSmallSelect; diff --git a/apps/jurumarble/src/app/search/components/SortSelect.tsx b/apps/jurumarble/src/app/search/components/SortSelect.tsx new file mode 100644 index 00000000..d9adee47 --- /dev/null +++ b/apps/jurumarble/src/app/search/components/SortSelect.tsx @@ -0,0 +1,59 @@ +import { SORT_LIST } from "lib/constants"; +import styled, { css } from "styled-components"; +import { useToggle } from "@monorepo/hooks"; +import SvgIcExpandMore from "src/assets/icons/components/IcExpandMore"; +import { Select } from "components/selectBox"; + +interface Props { + defaultOption: string; + onChangeSortOption: (id: string) => void; +} + +function SortSelect({ defaultOption, onChangeSortOption }: Props) { + const [isOpen, onToggleOpen] = useToggle(); + + return ( + + + + ); +} + +const SelectStyled = styled.span<{ isOpen: boolean }>` + ${({ theme, isOpen }) => css` + ${theme.typography.button01}; + color: ${theme.colors.black_03}; + width: 85px; + /* height: 40px; */ + .selected-label { + border: 1px solid ${theme.colors.line_01}; + border-radius: 8px; + padding: 10px 12px; + } + svg { + ${isOpen && "transform: rotateX( 180deg )"} + } + #select-list { + width: 100px; + height: 78px; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; + gap: 20px; + } + #indicator { + display: flex; + } + `} +`; + +export default SortSelect; diff --git a/apps/jurumarble/src/app/search/components/VoteDescription.tsx b/apps/jurumarble/src/app/search/components/VoteDescription.tsx new file mode 100644 index 00000000..49f36059 --- /dev/null +++ b/apps/jurumarble/src/app/search/components/VoteDescription.tsx @@ -0,0 +1,43 @@ +import Image, { StaticImageData } from "next/image"; +import styled from "styled-components"; + +interface Props { + imageA: string | StaticImageData; + imageB: string | StaticImageData; +} + +function VoteDescription({ imageA, imageB }: Props) { + return ( + + + + A 이미지 + + + B 이미지 + + + + ); +} + +const Container = styled.div``; + +const ImageWrapper = styled.div` + position: relative; + display: flex; + width: 100%; + height: 100%; + gap: 9px; +`; +const LeftVote = styled.div` + position: relative; + width: 50%; + aspect-ratio: 1; + display: flex; + justify-content: center; +`; + +const RightVote = styled(LeftVote)``; + +export default VoteDescription; diff --git a/apps/jurumarble/src/app/search/page.tsx b/apps/jurumarble/src/app/search/page.tsx index f18188b9..6c30ad1c 100644 --- a/apps/jurumarble/src/app/search/page.tsx +++ b/apps/jurumarble/src/app/search/page.tsx @@ -1,67 +1,204 @@ "use client"; import BottomBar from "components/BottomBar"; +import { Button } from "components/button"; +import SearchInput from "components/SearchInput"; +import useInput from "hooks/useInput"; import SvgIcPrev from "src/assets/icons/components/IcPrev"; -import SvgIcX from "src/assets/icons/components/IcX"; -import styled, { css } from "styled-components"; +import styled, { css, DefaultTheme } from "styled-components"; +import DrinkList from "./components/DrinkList"; +import DrinkVoteList from "./components/DrinkVoteList"; +import { useState } from "react"; +import { SORT_LIST } from "lib/constants"; +import SortSelect from "./components/SortSelect"; +import RegionSmallSelect from "./components/RegionSmallSelect"; + +const TAB_LIST = [ + { id: "total", name: "통합" }, + { id: "drinkInfo", name: "우리술 정보" }, + { id: "drinkVote", name: "우리술 투표" }, +]; function Search() { - return ( - - - + const { value: searchText, onChange: onChangeSearchText } = useInput({ + initialValue: "", + useDebounce: true, + }); + + const [selectedTab, setSelectedTab] = useState("total"); + const onClickSelectedTab = (e: React.MouseEvent) => { + setSelectedTab(e.currentTarget.value); + }; + + const [sortOption, setSortOption] = useState(SORT_LIST[1].value); + const onChangeSortOption = (value: string) => { + setSortOption(value); + }; - - - - - + const [regionOption, setRegionOption] = useState(""); + const onChangeRegionOption = (value: string) => { + setRegionOption(value); + }; + + const isSelectedTab = (tabName: string) => { + return selectedTab === tabName; + }; + + return ( + <> + + + + + + + {TAB_LIST.map(({ id, name }) => ( + + {name} + + ))} + + {!isSelectedTab("total") && ( + + + + + )} + {isSelectedTab("total") &&

우리술 정보

} + {isSelectedTab("drinkVote") ? ( + <> + ) : ( + <> + + {selectedTab === "total" && ( + + 우리술 정보 더보기 + + )} + + )} +
+ {isSelectedTab("total") && } + + {selectedTab === "drinkInfo" ? ( + <> + ) : ( + <> + {isSelectedTab("total") &&

우리술 투표

} + + {isSelectedTab("total") && ( + + 우리술 투표 더보기 + + )} + + )} +
-
+ ); } -const Container = styled.div` - height: 100vh; - overflow: hidden; +const TopSection = styled.section` + padding: 0 20px; `; const SearchBox = styled.div` - position: relative; - padding: 8px 20px; display: flex; align-items: center; - gap: 12px; - box-shadow: 0px 16px 32px 0px rgba(235, 235, 235, 0.6); + gap: 14px; `; -const SearchInput = styled.input` - flex: 1; - border-radius: 8px; - border: none; - padding: 12px; - &:focus { - outline: none; - } +const TabBox = styled.ul` + display: flex; + margin-top: 8px; +`; + +const DivideLine = styled.div` ${({ theme }) => css` - ${theme.typography.body02}; - background-color: ${theme.colors.bg_02}; - color: ${theme.colors.black_01}; - &::placeholder { - color: ${theme.colors.black_05}; - } + background-color: ${theme.colors.bg_01}; + height: 8px; `} `; -const ResetIconBox = styled.div` - position: absolute; - top: 0; - bottom: 0; - right: 32px; +const SelectedButton = styled.button<{ theme: DefaultTheme; selected: boolean }>` + ${({ theme, selected }) => + css` + ${theme.typography.body02}; + color: ${theme.colors.black_03}; + padding: 16.5px 12px; + + ${selected + ? css` + ${theme.typography.body01}; + color: ${theme.colors.black_01}; + border-bottom: 3px solid ${theme.colors.black_01}; + ` + : css` + display: flex; + `} + `} +`; + +const SelectContainer = styled.div` display: flex; - align-items: center; - justify-content: center; + margin-top: 40px; + gap: 4px; +`; + +const H2 = styled.h2` + ${({ theme }) => css` + ${theme.typography.headline02}; + color: ${theme.colors.black_01}; + margin-top: 40px; + `} +`; + +const MoreButton = styled(Button)` + ${({ theme }) => css` + ${theme.typography.body01} + margin: 24px 0 40px 0; + `}; +`; + +const BottomSection = styled.section` + padding: 0 20px 96px; + margin-top: 8px; // 64(BottomBar height) + 32(margin) = 96 + overflow: auto; `; export default Search; diff --git a/apps/jurumarble/src/app/search/services/useDrinkService.ts b/apps/jurumarble/src/app/search/services/useDrinkService.ts new file mode 100644 index 00000000..39d7cad9 --- /dev/null +++ b/apps/jurumarble/src/app/search/services/useDrinkService.ts @@ -0,0 +1,34 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { getDrinkList } from "lib/apis/drink"; +import { queryKeys } from "lib/queryKeys"; + +type SearchDrinkServiceProps = Exclude[0], undefined>; + +const getQueryKey = (params: SearchDrinkServiceProps) => [ + queryKeys.SEARCH_DRINK_LIST, + { ...params }, +]; + +export default function useSearchDrinkService(params: SearchDrinkServiceProps) { + const { data, fetchNextPage, hasNextPage } = useInfiniteQuery( + getQueryKey(params), + ({ pageParam }) => + getDrinkList({ + ...params, + page: pageParam?.page || params.page, + }), + { + getNextPageParam: ({ last, number }) => { + if (last) return undefined; + return { + page: number + 1, + }; + }, + keepPreviousData: true, + }, + ); + + const drinkList = data?.pages.flatMap((page) => page.content) ?? []; + + return { drinkList, fetchNextPage, hasNextPage }; +} diff --git a/apps/jurumarble/src/app/search/services/useVoteDrinkService.ts b/apps/jurumarble/src/app/search/services/useVoteDrinkService.ts new file mode 100644 index 00000000..a4b75024 --- /dev/null +++ b/apps/jurumarble/src/app/search/services/useVoteDrinkService.ts @@ -0,0 +1,34 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { getVoteDrinkList } from "lib/apis/vote"; +import { queryKeys } from "lib/queryKeys"; + +type SearchVoteDrinkServiceProps = Exclude[0], undefined>; + +const getQueryKey = (params: SearchVoteDrinkServiceProps) => [ + queryKeys.SEARCH_VOTE_DRINK_LIST, + { ...params }, +]; + +export default function useVoteDrinkService(params: SearchVoteDrinkServiceProps) { + const { data, fetchNextPage, hasNextPage } = useInfiniteQuery( + getQueryKey(params), + ({ pageParam }) => + getVoteDrinkList({ + ...params, + page: pageParam?.page || params.page, + }), + { + getNextPageParam: ({ last, number }) => { + if (last) return undefined; + return { + page: number + 1, + }; + }, + keepPreviousData: true, + }, + ); + + const voteDrinkList = data?.pages.flatMap((page) => page.content) ?? []; + + return { voteDrinkList, fetchNextPage, hasNextPage }; +} diff --git a/apps/jurumarble/src/app/vote/[id]/components/ChipContainer.tsx b/apps/jurumarble/src/app/vote/[id]/components/ChipContainer.tsx index 5fab983e..da79a6bd 100644 --- a/apps/jurumarble/src/app/vote/[id]/components/ChipContainer.tsx +++ b/apps/jurumarble/src/app/vote/[id]/components/ChipContainer.tsx @@ -1,4 +1,5 @@ import { UseMutateFunction } from "@tanstack/react-query"; +import Chip from "components/Chip"; import React from "react"; import SvgIcBookmarkActive from "src/assets/icons/components/IcBookmarkActive"; import SvgIcBookmark from "src/assets/icons/components/IcBookmark"; @@ -23,8 +24,8 @@ const ChipContainer = ({ date, description, title, region, mutateBookMark, isBoo <> - {region && {region}} - {/* 122명이 즐겼어요 */} + {region && {region}} + {/* 122명이 즐겼어요 */} {isBookmark ? ( @@ -86,19 +87,6 @@ const FlexRow = styled.div` gap: 8px; `; -const NormalTag = styled.div` - padding: 10px 8px; - ${({ theme }) => theme.typography.caption} - background-color: ${({ theme }) => theme.colors.bg_01}; - color: ${({ theme }) => theme.colors.black_01}; - border-radius: 4px; -`; - -const RegionTag = styled(NormalTag)` - background-color: ${({ theme }) => theme.colors.main_02}; - color: ${({ theme }) => theme.colors.main_01}; -`; - const Description = styled.div` padding-bottom: 20px; ${({ theme }) => theme.typography.body_long03} diff --git a/apps/jurumarble/src/app/vote/[id]/components/SearchRestaurantModal.tsx b/apps/jurumarble/src/app/vote/[id]/components/SearchRestaurantModal.tsx index 0848fe1b..3aaa84f7 100644 --- a/apps/jurumarble/src/app/vote/[id]/components/SearchRestaurantModal.tsx +++ b/apps/jurumarble/src/app/vote/[id]/components/SearchRestaurantModal.tsx @@ -1,14 +1,13 @@ "use client"; import { Button, ModalTemplate } from "components/index"; -import SearchInput from "components/SearchInput"; import VoteHeader from "components/VoteHeader"; -import { ThemeColors, transitions } from "lib/styles"; +import { transitions } from "lib/styles"; import Image from "next/image"; import { EmptyAImg } from "public/images"; import { useState } from "react"; import SvgIcX from "src/assets/icons/components/IcX"; -import styled, { css } from "styled-components"; +import styled, { css, DefaultTheme } from "styled-components"; import RestaurantItem from "./RestaurantItem"; interface Props { @@ -139,7 +138,7 @@ const FoodItem = styled.div` position: relative; `; -const ColorBox = styled.div<{ theme: ThemeColors; selected: boolean }>` +const ColorBox = styled.div<{ theme: DefaultTheme; selected: boolean }>` ${({ theme, selected }) => css` border-radius: 4px; diff --git a/apps/jurumarble/src/app/vote/[id]/components/VoteDescription.tsx b/apps/jurumarble/src/app/vote/[id]/components/VoteDescription.tsx index 3930a0de..d9ecba5a 100644 --- a/apps/jurumarble/src/app/vote/[id]/components/VoteDescription.tsx +++ b/apps/jurumarble/src/app/vote/[id]/components/VoteDescription.tsx @@ -98,7 +98,7 @@ const Container = styled.div``; const ImageWrapper = styled.div` position: relative; display: flex; - width: 100%;z + width: 100%; height: 100%; gap: 9px; `; diff --git a/apps/jurumarble/src/app/vote/page.tsx b/apps/jurumarble/src/app/vote/page.tsx index 989d3624..2486e0e8 100644 --- a/apps/jurumarble/src/app/vote/page.tsx +++ b/apps/jurumarble/src/app/vote/page.tsx @@ -11,10 +11,10 @@ import SvgIcDetail from "src/assets/icons/components/IcDetail"; import styled, { css } from "styled-components"; import useFlipAnimation from "./hooks/useFlipAnimation"; import useInfiniteMainListService from "./post/services/useGetVoteListService"; -import usePostBookmarkService from "./post/services/useBookmarkService"; import ChipContainer from "./[id]/components/ChipContainer"; import VoteDescription from "./[id]/components/VoteDescription"; import Path from "lib/Path"; +import useBookmarkService from "services/useBookmarkService"; export type Drag = "up" | "down" | null; @@ -37,7 +37,7 @@ function VoteHomePage() { const { title, imageA, imageB, titleA, titleB, detail, voteId, region } = mainVoteList[nowShowing] || {}; - const { mutateBookMark, bookMarkCheckQuery } = usePostBookmarkService(voteId); + const { mutateBookMark, bookMarkCheckQuery } = useBookmarkService(voteId); const { data: bookmarkCheck } = bookMarkCheckQuery; diff --git a/apps/jurumarble/src/app/vote/post/components/DrinkSearchModal.tsx b/apps/jurumarble/src/app/vote/post/components/DrinkSearchModal.tsx index ee9bd230..5ba104b2 100644 --- a/apps/jurumarble/src/app/vote/post/components/DrinkSearchModal.tsx +++ b/apps/jurumarble/src/app/vote/post/components/DrinkSearchModal.tsx @@ -7,10 +7,10 @@ import { useState } from "react"; import SvgIcX from "src/assets/icons/components/IcX"; import styled, { css } from "styled-components"; import useUpdateSelectedDrinkList from "../hooks/useUpdateSelectedDrinkList"; -import DrinkItem from "./DrinkItem"; import SearchInput from "../../../../components/SearchInput"; import RegionSelect from "./RegionSelect"; import SelectedDrinkChip from "./SelectedDrinkChip"; +import DrinkItem from "components/DrinkItem"; interface Props { onToggleDrinkSearchModal: () => void; @@ -57,7 +57,7 @@ function DrinkSearchModal({ onToggleDrinkSearchModal }: Props) { regionOption={regionOption} onChangeRegionOption={onChangeRegionOption} > - {regionOption && } + {/* {regionOption && } */} {selectedDrinkList.map((manufacturer) => ( @@ -69,15 +69,15 @@ function DrinkSearchModal({ onToggleDrinkSearchModal }: Props) { {regionOption && ( - {TEMP_LIST.map(({ drinkName, manufacturer }) => ( + {/* {TEMP_LIST.map(({ drinkName, manufacturer }) => ( - ))} + ))} */} 선택 완료 diff --git a/apps/jurumarble/src/app/vote/post/components/RegionSelect.tsx b/apps/jurumarble/src/app/vote/post/components/RegionSelect.tsx index 78a70c14..2d2f27a7 100644 --- a/apps/jurumarble/src/app/vote/post/components/RegionSelect.tsx +++ b/apps/jurumarble/src/app/vote/post/components/RegionSelect.tsx @@ -51,6 +51,9 @@ const SelectStyled = styled.div<{ isOpen: boolean }>` left: 22px; width: 335px; height: 330px; + display: flex; + flex-direction: column; + gap: 20px; } li { display: flex; diff --git a/apps/jurumarble/src/app/vote/post/services/useGetVoteListService.tsx b/apps/jurumarble/src/app/vote/post/services/useGetVoteListService.tsx index ff2fdbce..a3b2651a 100644 --- a/apps/jurumarble/src/app/vote/post/services/useGetVoteListService.tsx +++ b/apps/jurumarble/src/app/vote/post/services/useGetVoteListService.tsx @@ -2,10 +2,13 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { getVoteListAPI } from "lib/apis/vote"; import { reactQueryKeys } from "lib/queryKeys"; import { useEffect, useMemo, useState } from "react"; +import { SortType } from "src/types/common"; + +type VoteListSortType = Omit; interface Props { size: number; - sortBy: "ByTime" | "ByPopularity"; + sortBy: VoteListSortType; keyword?: string; } diff --git a/apps/jurumarble/src/assets/icons/components/IcExpandMore.tsx b/apps/jurumarble/src/assets/icons/components/IcExpandMore.tsx new file mode 100644 index 00000000..cdd90dbf --- /dev/null +++ b/apps/jurumarble/src/assets/icons/components/IcExpandMore.tsx @@ -0,0 +1,33 @@ +import type { SVGProps } from "react"; +const SvgIcExpandMore = (props: SVGProps) => ( + + + + + + + + +); +export default SvgIcExpandMore; diff --git a/apps/jurumarble/src/components/Chip.tsx b/apps/jurumarble/src/components/Chip.tsx new file mode 100644 index 00000000..140a8c07 --- /dev/null +++ b/apps/jurumarble/src/components/Chip.tsx @@ -0,0 +1,38 @@ +import styled, { css } from "styled-components"; + +interface Props { + variant: "region" | "numberOfParticipants"; + children: React.ReactNode; +} + +type variant = Pick; + +function Chip({ variant, children }: Props) { + return {children}; +} + +const variantStyles = { + region: css` + ${({ theme }) => css` + background-color: ${theme.colors.main_02}; + color: ${theme.colors.main_01}; + `} + `, + numberOfParticipants: css` + ${({ theme }) => css` + background-color: ${theme.colors.bg_01}; + color: ${theme.colors.black_02}; + `} + `, +}; + +const Wrapper = styled.div` + ${({ theme, variant }) => css` + ${variant && variantStyles[variant]}; + ${theme.typography.caption} + padding: 10px 8px; + border-radius: 4px; + `} +`; + +export default Chip; diff --git a/apps/jurumarble/src/app/vote/post/components/DrinkItem.tsx b/apps/jurumarble/src/components/DrinkItem.tsx similarity index 59% rename from apps/jurumarble/src/app/vote/post/components/DrinkItem.tsx rename to apps/jurumarble/src/components/DrinkItem.tsx index 86fcfade..812e3128 100644 --- a/apps/jurumarble/src/app/vote/post/components/DrinkItem.tsx +++ b/apps/jurumarble/src/components/DrinkItem.tsx @@ -1,52 +1,58 @@ +import Chip from "components/Chip"; +import { DrinkInfo } from "lib/apis/drink"; +import Path from "lib/Path"; import { transitions } from "lib/styles"; -import Image, { StaticImageData } from "next/image"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; import SvgStamp from "src/assets/icons/components/IcStamp"; import styled, { css, useTheme } from "styled-components"; interface Props { - staticImage: StaticImageData; - drinkName: string; - manufacturer: string; + drinkInfo: DrinkInfo; stamp?: boolean; - onClickAddDrink: (e: React.MouseEvent) => void; - selectedDrinkList: string[]; + onClickAddDrink?: (e: React.MouseEvent) => void; + selectedDrinkList?: string[]; } -function DrinkItem({ - staticImage, - drinkName = "제품명", - manufacturer = "제조사", - stamp, - onClickAddDrink, - selectedDrinkList, -}: Props) { +function DrinkItem({ drinkInfo, stamp, onClickAddDrink, selectedDrinkList }: Props) { + const { id, name, productName, image } = drinkInfo; + const { colors } = useTheme(); + + const router = useRouter(); + const onClickDrinkItem = () => { + router.push(`${Path.DRINK_INFO_PAGE}/${id}`); + }; + return ( - 임시 이미지 + 임시 이미지 - {drinkName} + {name} {stamp && ( )} - {manufacturer} - + {productName} + + 서울 + 213명이 즐겼어요 + ); } -const Container = styled.button<{ selected: boolean }>` +const Container = styled.button<{ selected: boolean | undefined }>` display: flex; box-shadow: 0px 2px 8px 0px rgba(235, 235, 235, 0.4), 0px 8px 20px 0px rgba(235, 235, 235, 0.4); height: 120px; @@ -104,6 +110,10 @@ const ManufacturerName = styled.span` `} `; -const ChipContainer = styled.div``; +const ChipContainer = styled.div` + display: flex; + margin-top: 13px; + gap: 4px; +`; export default DrinkItem; diff --git a/apps/jurumarble/src/components/SearchInput.tsx b/apps/jurumarble/src/components/SearchInput.tsx index 25827a28..623da02e 100644 --- a/apps/jurumarble/src/components/SearchInput.tsx +++ b/apps/jurumarble/src/components/SearchInput.tsx @@ -1,25 +1,34 @@ import { Button } from "components/button"; import { Input } from "components/input"; +import { SvgIcX } from "src/assets/icons/components"; import SvgIcSearch from "src/assets/icons/components/IcSearch"; import styled, { css, useTheme } from "styled-components"; interface Props { + value: string; + onChange: (e: React.ChangeEvent) => void; placeholder?: string; } -function SearchInput({ placeholder }: Props) { +function SearchInput({ value, onChange, placeholder }: Props) { const theme = useTheme(); return ( - + + {/* */} ); } -const Search = styled.div` +const Search = styled.form` display: flex; margin-top: 8px; width: 100%; diff --git a/apps/jurumarble/src/components/button/Chip.tsx b/apps/jurumarble/src/components/button/Chip.tsx deleted file mode 100644 index cf6b729f..00000000 --- a/apps/jurumarble/src/components/button/Chip.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; - -// TODO: Chip 컴포넌트 구현 -function Chip() { - return
Chip
; -} - -export default Chip; diff --git a/apps/jurumarble/src/components/selectBox/Option.tsx b/apps/jurumarble/src/components/selectBox/Option.tsx index fd69fb51..fe5a66e9 100644 --- a/apps/jurumarble/src/components/selectBox/Option.tsx +++ b/apps/jurumarble/src/components/selectBox/Option.tsx @@ -14,7 +14,9 @@ function Option({ label, onChangeSelectedOption }: Props) { } const Li = styled.li` - padding: 14px 34px; + display: flex; + align-items: center; + justify-content: center; `; export default Option; diff --git a/apps/jurumarble/src/lib/Path.ts b/apps/jurumarble/src/lib/Path.ts index e4aa5c8a..dd227db0 100644 --- a/apps/jurumarble/src/lib/Path.ts +++ b/apps/jurumarble/src/lib/Path.ts @@ -18,6 +18,7 @@ const Path = { MAIN_PAGE: "/", VOTE_DETAIL_PAGE: "/detail", SEARCH_PAGE: "/search", + DRINK_INFO_PAGE: "/drink-info", } as const; export default Path; diff --git a/apps/jurumarble/src/lib/apis/bookmark.ts b/apps/jurumarble/src/lib/apis/bookmark.ts index 413a5199..b34aaf2d 100644 --- a/apps/jurumarble/src/lib/apis/bookmark.ts +++ b/apps/jurumarble/src/lib/apis/bookmark.ts @@ -1,4 +1,4 @@ -import { http } from "./http"; +import { http } from "./http/http"; export const postBookmarkAPI = async (voteId: number) => { const response = await http.post(`api/votes/${voteId}/bookmark`, { voteId }); diff --git a/apps/jurumarble/src/lib/apis/common.ts b/apps/jurumarble/src/lib/apis/common.ts index f5bded25..93f1e681 100644 --- a/apps/jurumarble/src/lib/apis/common.ts +++ b/apps/jurumarble/src/lib/apis/common.ts @@ -1,4 +1,4 @@ -import { http } from "./http"; +import { http } from "./http/http"; export interface postVoteResponse { imageUrl: string; diff --git a/apps/jurumarble/src/lib/apis/drink.ts b/apps/jurumarble/src/lib/apis/drink.ts new file mode 100644 index 00000000..0e917a11 --- /dev/null +++ b/apps/jurumarble/src/lib/apis/drink.ts @@ -0,0 +1,62 @@ +import { SortType } from "src/types/common"; +import { baseApi } from "./http/base"; + +export interface GetDrinkListRequest { + page: number; + size: number; + keyword?: string; + region?: string; + sortBy: SortType; +} + +interface GetDrinkListResponse { + sort: Sort; + first: boolean; + last: boolean; + number: number; + numberOfElements: number; + pageable: Pageable; + size: number; + content: DrinkInfo[]; + empty: boolean; +} + +export interface DrinkInfo { + id: number; + name: string; + type: string; + productName: string; + alcoholicBeverage: string; + rawMaterial: string; + capacity: string; + manufactureAddress: string; + region: string; + price: string; + image: string; + latitude: number; + longitude: number; + enjoyCount: number; +} + +interface Pageable { + sort: Sort; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; + offset: number; +} + +interface Sort { + sorted: boolean; + unsorted: boolean; + empty: boolean; +} +export const getDrinkList = async (params: GetDrinkListRequest) => { + const response = await baseApi.get("api/drinks", { + params: { + ...params, + }, + }); + return response.data; +}; diff --git a/apps/jurumarble/src/lib/apis/http/base.ts b/apps/jurumarble/src/lib/apis/http/base.ts new file mode 100644 index 00000000..5b986ff5 --- /dev/null +++ b/apps/jurumarble/src/lib/apis/http/base.ts @@ -0,0 +1,8 @@ +import axios from "axios"; +import { SERVER_URL } from "lib/constants"; + +const axiosInstance = axios.create({ + baseURL: SERVER_URL, +}); + +export const baseApi = axiosInstance; diff --git a/apps/jurumarble/src/lib/apis/http.ts b/apps/jurumarble/src/lib/apis/http/http.ts similarity index 69% rename from apps/jurumarble/src/lib/apis/http.ts rename to apps/jurumarble/src/lib/apis/http/http.ts index 967ddf4f..37b17a35 100644 --- a/apps/jurumarble/src/lib/apis/http.ts +++ b/apps/jurumarble/src/lib/apis/http/http.ts @@ -2,6 +2,7 @@ import axios from "axios"; import type { AxiosInstance, AxiosRequestConfig } from "axios"; import { SERVER_URL } from "lib/constants"; import userStorage from "lib/utils/userStorage"; +import { logout } from "lib/utils/auth"; const axiosInstance = axios.create({ baseURL: SERVER_URL, @@ -22,7 +23,33 @@ export const http = axiosInstance; axiosInstance.interceptors.response.use( (response) => response, - (originalError) => Promise.reject(originalError), + + async (error) => { + const { + response: { status }, + } = error; + + switch (status) { + case 401: + const tokens = userStorage.get(); + if (!tokens) throw new Error("No tokens found"); + + alert("로그인이 만료되었습니다. 다시 로그인해주세요."); + + logout(); + + break; + + case 409: + throw new Error(error.response.data.message); + + case 500: + throw new Error(error.response.data.message); + + default: + throw new Error("Unknown Error"); + } + }, ); axiosInstance.interceptors.request.use((config) => { diff --git a/apps/jurumarble/src/lib/apis/restaurant.ts b/apps/jurumarble/src/lib/apis/restaurant.ts index 06c51b62..29d3a3f2 100644 --- a/apps/jurumarble/src/lib/apis/restaurant.ts +++ b/apps/jurumarble/src/lib/apis/restaurant.ts @@ -1,5 +1,5 @@ import { SERVER_URL } from "lib/constants"; -import { http } from "./http"; +import { http } from "./http/http"; interface GetRestaurantRequest { voteId: number; diff --git a/apps/jurumarble/src/lib/apis/vote.ts b/apps/jurumarble/src/lib/apis/vote.ts index 00cb12c5..41eb57dc 100644 --- a/apps/jurumarble/src/lib/apis/vote.ts +++ b/apps/jurumarble/src/lib/apis/vote.ts @@ -1,14 +1,17 @@ -import axios from "axios"; import { SERVER_URL } from "lib/constants"; +import { SortType } from "src/types/common"; +import { baseApi } from "./http/base"; + +type VoteListSortType = Omit; export interface GetVoteListRequest { keyword?: string; - sortBy: "ByTime" | "ByPopularity"; + sortBy: VoteListSortType; page: number; size: number; } -interface Vote { +export interface Vote { voteId: number; postedUserId: number; title: string; @@ -34,7 +37,7 @@ interface GetVoteListResponse { } export const getVoteListAPI = async ({ page, size, sortBy, keyword }: GetVoteListRequest) => { - const response = await axios.get(`${SERVER_URL}api/votes`, { + const response = await baseApi.get("api/votes", { params: { page, size, @@ -133,3 +136,49 @@ export const postDrinkVoteAPI = async (voteInfo: PostDrinkVoteRequest) => { const res = await response.json(); return res.data; }; + +export interface GetVoteDrinkListRequest { + page: number; + size: number; + keyword?: string; + region?: string; + sortBy: string; +} + +interface Pageable { + sort: Sort; + pageNumber: number; + pageSize: number; + offset: number; + paged: boolean; + unpaged: boolean; +} + +interface Sort { + sorted: boolean; + unsorted: boolean; + empty: boolean; +} + +export interface GetVotetDrinkListResponse { + voteSlice: { + content: Vote[]; + pageable: Pageable; + sort: Sort; + first: boolean; + last: boolean; + number: number; + numberOfElements: number; + size: number; + empty: boolean; + }; +} + +export const getVoteDrinkList = async (params: GetVoteDrinkListRequest) => { + const response = await baseApi.get("api/votes/drinks", { + params: { + ...params, + }, + }); + return response.data.voteSlice; +}; diff --git a/apps/jurumarble/src/lib/constants.ts b/apps/jurumarble/src/lib/constants.ts index e1a71dce..e0d31541 100644 --- a/apps/jurumarble/src/lib/constants.ts +++ b/apps/jurumarble/src/lib/constants.ts @@ -14,6 +14,10 @@ export const NAVER_LOGIN_REDIRECT_URL = ? `http://localhost:3000/${Path.NAVER_LOGIN_PROCESS}` : `${CLIENT_URL}${Path.NAVER_LOGIN_PROCESS}`; +/** + * @Todo type으로 사용할 수 있는 방법 알아보기 + */ + export const REGION_LIST = [ { value: "SEOUL", label: "서울" }, { value: "INCHEON", label: "인천" }, @@ -33,3 +37,9 @@ export const REGION_LIST = [ { value: "JEONNAM", label: "전라남도" }, { value: "JEJU", label: "제주" }, ]; + +export const SORT_LIST = [ + { value: "ByName", label: "이름순" }, + { value: "ByPopularity", label: "인기순" }, + { value: "ByTime", label: "최신순" }, +]; diff --git a/apps/jurumarble/src/lib/queryKeys.ts b/apps/jurumarble/src/lib/queryKeys.ts index d49ea6cd..34af1717 100644 --- a/apps/jurumarble/src/lib/queryKeys.ts +++ b/apps/jurumarble/src/lib/queryKeys.ts @@ -1,10 +1,18 @@ export const queryKeys = { MAIN_VOTE_LIST: "mainVoteList" as const, BOOKMARK_CHECK: "bookmarkCheck" as const, + USER_INFO: "userInfo" as const, + VOTE_LIST: "voteList" as const, + RESTAURANT_LIST: "restaurantList" as const, + SEARCH_DRINK_LIST: "searchDrinkList" as const, + SEARCH_VOTE_DRINK_LIST: "searchVoteDrinkList" as const, }; export const reactQueryKeys = { // @note any 처리 mainVoteList: () => [queryKeys.MAIN_VOTE_LIST] as const, bookmarkCheck: () => [queryKeys.BOOKMARK_CHECK] as const, + userInfo: () => [queryKeys.USER_INFO], + voteList: (params: any) => [queryKeys.VOTE_LIST, ...params], + restaurantList: (params: any) => [queryKeys.RESTAURANT_LIST, ...params], }; diff --git a/apps/jurumarble/src/app/vote/post/services/useBookmarkService.ts b/apps/jurumarble/src/services/useBookmarkService.ts similarity index 93% rename from apps/jurumarble/src/app/vote/post/services/useBookmarkService.ts rename to apps/jurumarble/src/services/useBookmarkService.ts index 429c46d6..100aa1b9 100644 --- a/apps/jurumarble/src/app/vote/post/services/useBookmarkService.ts +++ b/apps/jurumarble/src/services/useBookmarkService.ts @@ -5,7 +5,7 @@ import { queryKeys, reactQueryKeys } from "lib/queryKeys"; import { useRouter } from "next/navigation"; import { toast } from "react-toastify"; -export default function usePostBookmarkService(voteId: number) { +export default function useBookmarkService(voteId: number) { const queryClient = useQueryClient(); const bookMarkCheckQuery = useQuery( diff --git a/apps/jurumarble/src/types/common.ts b/apps/jurumarble/src/types/common.ts new file mode 100644 index 00000000..8204566c --- /dev/null +++ b/apps/jurumarble/src/types/common.ts @@ -0,0 +1 @@ +export type SortType = "ByTime" | "ByName" | "ByPopularity"; diff --git a/apps/jurumarble/tsconfig.json b/apps/jurumarble/tsconfig.json index 652da1fd..31ae73b9 100644 --- a/apps/jurumarble/tsconfig.json +++ b/apps/jurumarble/tsconfig.json @@ -18,7 +18,8 @@ "styles/*": ["./src/styles/*"], "lib/*": ["./src/lib/*"], "hooks/*": ["./src/hooks/*"], - "public/*": ["./public/*"] + "public/*": ["./public/*"], + "services/*": ["./src/services/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/packages/hooks/package.json b/packages/hooks/package.json index ddb5eaac..59054649 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -6,12 +6,12 @@ "license": "ISC", "version": "0.1.0", "main": "index.ts", - "dependencies": { - "react": "^18.2.0" - }, "devDependencies": { "@types/node": "20.5.7", "@types/react": "18.2.21", "typescript": "4.9.3" + }, + "peerDependencies": { + "react": "^18.2.0" } } diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index bf5f3e08..42ce3f79 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,3 +1,5 @@ export { default as useToggle } from "./useToggle"; export { default as useOutsideClick } from "./useOutsideClick"; export { default as useDebounce } from "./useDebounce"; +export { default as useInfiniteScroll } from "./useInfiniteScroll"; +export { default as useIntersectionObserver } from "./useIntersectionObserver"; diff --git a/packages/hooks/src/useInfiniteScroll.ts b/packages/hooks/src/useInfiniteScroll.ts new file mode 100644 index 00000000..4384775b --- /dev/null +++ b/packages/hooks/src/useInfiniteScroll.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import useIntersectionObserver from "./useIntersectionObserver"; + +/** + * 무한 스크롤에 사용하는 hooks + * + * ### 예제 + * ```tsx + * const [subscribe] = useInfiniteScroll(onLoadMore, { + * rootMargin: '100px', + * }); + * ... + * return ( + *
+ * ... + * ``` + */ +export default function useInfiniteScroll( + onLoadMore: () => unknown, + options?: IntersectionObserverInit, +) { + const subscriber = useCallback( + ([entry]: IntersectionObserverEntry[]) => { + if (!entry?.isIntersecting) { + return; + } + onLoadMore(); + }, + [onLoadMore], + ); + + return useIntersectionObserver(subscriber, options); +} diff --git a/packages/hooks/src/useIntersectionObserver.ts b/packages/hooks/src/useIntersectionObserver.ts new file mode 100644 index 00000000..f41f153d --- /dev/null +++ b/packages/hooks/src/useIntersectionObserver.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type Result = [(node: T | null) => void]; + +export default function useIntersectionObserver( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, +): Result { + const [target, setTarget] = useState(null); + const observer = useRef(null); + + const subscribe = useCallback( + (node: T | null) => { + if (node) { + observer.current?.observe(node); + setTarget(node); + } + }, + [observer.current], + ); + + useEffect(() => { + observer.current = new IntersectionObserver(callback, options); + subscribe(target); + + return () => { + observer.current?.disconnect(); + observer.current = null; + }; + }, [callback, options]); + + return [subscribe]; +} diff --git a/yarn.lock b/yarn.lock index 1fe479fe..c66d2aeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1585,8 +1585,9 @@ __metadata: dependencies: "@types/node": 20.5.7 "@types/react": 18.2.21 - react: ^18.2.0 typescript: 4.9.3 + peerDependencies: + react: ^18.2.0 languageName: unknown linkType: soft