diff --git a/apps/web/app/HydrationBoundaryPage.tsx b/apps/web/app/HydrationBoundaryPage.tsx index 9f208cf..335af0f 100644 --- a/apps/web/app/HydrationBoundaryPage.tsx +++ b/apps/web/app/HydrationBoundaryPage.tsx @@ -6,49 +6,35 @@ import { import { ReactNode } from 'react' /** - * React Query 쿼리 구성 타입 + * 서버 컴포넌트에서 React Query 쿼리를 미리 요청(prefetch)하고, + * 클라이언트로 전달하여 초기 데이터를 사용할 수 있도록 해주는 컴포넌트입니다. * - * @property queryKey - React Query에서 사용할 쿼리 키 - * @property queryFn - 데이터를 가져오는 비동기 함수 - */ -type QueryConfig = { - queryKey: string[] - queryFn: () => Promise -} - -/** - * 서버 컴포넌트에서 React Query 쿼리를 미리 요청(prefetch)한 뒤, - * dehydrate 상태를 클라이언트에 전달하기 위한 컴포넌트. + * @param children - HydrationBoundary로 감쌀 React 노드 + * @param prefetch - 서버에서 실행할 prefetch 함수. QueryClient를 받아서 필요한 쿼리를 모두 prefetch하도록 구현합니다. + * + * @returns HydrationBoundary로 감싼 children * * @example * ```tsx * { + * await queryClient.prefetchQuery(useCategoryQueries.list()) + * await queryClient.prefetchQuery(usePlaceQueries.rankingList('likes')) + * }} * > - * + * * * ``` - * - * @param queries - 사전 요청할 쿼리들의 배열 - * @param children - HydrationBoundary로 감쌀 React 노드 */ export const HydrationBoundaryPage = async ({ - queries, children, + prefetch, }: { - queries: QueryConfig[] children: ReactNode + prefetch: (queryClient: QueryClient) => Promise }) => { const queryClient = new QueryClient() - - await Promise.all( - queries.map(({ queryKey, queryFn }) => - queryClient.prefetchQuery({ queryKey, queryFn }), - ), - ) + await prefetch(queryClient) return ( diff --git a/apps/web/app/_apis/queries/category.ts b/apps/web/app/_apis/queries/category.ts new file mode 100644 index 0000000..83519e2 --- /dev/null +++ b/apps/web/app/_apis/queries/category.ts @@ -0,0 +1,17 @@ +import { queryOptions } from '@tanstack/react-query' +import { getCategories } from '@/_apis/services/category' + +export const CategoryQueryKeys = { + all: () => ['category'] as const, + list: () => [...CategoryQueryKeys.all(), 'list'] as const, + items: (categoryId: string) => + [...CategoryQueryKeys.all(), 'items', categoryId] as const, +} + +export const useCategoryQueries = { + list: () => + queryOptions({ + queryKey: CategoryQueryKeys.list(), + queryFn: getCategories, + }), +} diff --git a/apps/web/app/_apis/queries/place.ts b/apps/web/app/_apis/queries/place.ts new file mode 100644 index 0000000..4553381 --- /dev/null +++ b/apps/web/app/_apis/queries/place.ts @@ -0,0 +1,17 @@ +import { queryOptions } from '@tanstack/react-query' +import { RankingPlaceSort } from '@/_apis/schemas/place' +import { getRankingPlaces } from '@/_apis/services/place' + +export const PlaceQueryKeys = { + all: () => ['place'] as const, + rankingList: (sort: RankingPlaceSort) => + [...PlaceQueryKeys.all(), 'ranking', sort] as const, +} + +export const usePlaceQueries = { + rankingList: (sort: RankingPlaceSort) => + queryOptions({ + queryKey: PlaceQueryKeys.rankingList(sort), + queryFn: () => getRankingPlaces(sort), + }), +} diff --git a/apps/web/app/_apis/schemas/category.ts b/apps/web/app/_apis/schemas/category.ts new file mode 100644 index 0000000..292973f --- /dev/null +++ b/apps/web/app/_apis/schemas/category.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' +import { IconList } from '@repo/ui/components/Icon/IconMap' + +export const CategorySchema = z.object({ + id: z.number().transform(String), + name: z.string(), + iconKey: z.enum(IconList), +}) + +export type Category = z.infer diff --git a/apps/web/app/_apis/schemas/place.ts b/apps/web/app/_apis/schemas/place.ts new file mode 100644 index 0000000..8615bd4 --- /dev/null +++ b/apps/web/app/_apis/schemas/place.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { CategorySchema } from '@/_apis/schemas/category' + +export const BasePlaceSchema = z.object({ + placeId: z.number().transform(String), + placeName: z.string(), + address: z.string(), + categories: z.array(CategorySchema), + tags: z.array(CategorySchema), +}) + +export type RankingPlaceSort = 'views' | 'likes' + +export const RankingPlaceSchema = BasePlaceSchema.extend({ + isLiked: z.boolean(), + likeCount: z.number().int().nonnegative(), +}) + +export type BasePlace = z.infer +export type RankingPlace = z.infer diff --git a/apps/web/app/_apis/services/category.ts b/apps/web/app/_apis/services/category.ts new file mode 100644 index 0000000..0e84cce --- /dev/null +++ b/apps/web/app/_apis/services/category.ts @@ -0,0 +1,8 @@ +import axiosInstance from '@/_lib/axiosInstance' +import { API_PATH } from '@/_constants/path' +import { CategorySchema, Category } from '../schemas/category' + +export const getCategories = async (): Promise => { + const { data } = await axiosInstance.get(API_PATH.CATEGORY) + return CategorySchema.array().parse(data) +} diff --git a/apps/web/app/_apis/services/place.ts b/apps/web/app/_apis/services/place.ts new file mode 100644 index 0000000..3221457 --- /dev/null +++ b/apps/web/app/_apis/services/place.ts @@ -0,0 +1,14 @@ +import axiosInstance from '@/_lib/axiosInstance' +import { API_PATH } from '@/_constants/path' +import { + type RankingPlace, + type RankingPlaceSort, + RankingPlaceSchema, +} from '../schemas/place' + +export const getRankingPlaces = async ( + sort: RankingPlaceSort, +): Promise => { + const { data } = await axiosInstance.get(API_PATH.RANKING(sort)) + return RankingPlaceSchema.array().parse(data) +} diff --git a/apps/web/app/_components/Banner/Banner.tsx b/apps/web/app/_components/Banner/Banner.tsx new file mode 100644 index 0000000..f668009 --- /dev/null +++ b/apps/web/app/_components/Banner/Banner.tsx @@ -0,0 +1,79 @@ +'use client' + +import 'keen-slider/keen-slider.min.css' +import { useKeenSlider } from 'keen-slider/react' + +type Props = { + contents: React.ReactNode[] + minHeight?: number +} + +/** + * Banner 컴포넌트 + * + * - 여러 콘텐츠를 순차적으로 보여주는 슬라이더 배너입니다. + * - `keen-slider`를 기반으로 자동 재생(loop) 기능을 제공합니다. + * - 마우스를 올리면 자동 재생이 일시 정지되고, 마우스를 치우면 다시 재생됩니다. + * + * @param contents 렌더링할 React 노드 배열 (각각의 배너 콘텐츠) + * @param minHeight 배너의 최소 높이(px). 기본값은 150입니다. + * + * @example + * ```tsx + * 배너 1, + *
배너 2
, + *
배너 3
, + * ]} + * minHeight={200} + * /> + * ``` + */ +export const Banner = ({ contents, minHeight = 150 }: Props) => { + const [sliderRef] = useKeenSlider( + { + loop: true, + }, + [ + (slider) => { + let timeout: ReturnType + let mouseOver = false + function clearNextTimeout() { + clearTimeout(timeout) + } + function nextTimeout() { + clearTimeout(timeout) + if (mouseOver) return + timeout = setTimeout(() => { + slider.next() + }, 2000) + } + slider.on('created', () => { + slider.container.addEventListener('mouseover', () => { + mouseOver = true + clearNextTimeout() + }) + slider.container.addEventListener('mouseout', () => { + mouseOver = false + nextTimeout() + }) + nextTimeout() + }) + slider.on('dragStarted', clearNextTimeout) + slider.on('animationEnded', nextTimeout) + slider.on('updated', nextTimeout) + }, + ], + ) + + return ( +
+ {contents.map((content, index) => ( +
+ {content} +
+ ))} +
+ ) +} diff --git a/apps/web/app/_components/Banner/index.tsx b/apps/web/app/_components/Banner/index.tsx new file mode 100644 index 0000000..1a83a85 --- /dev/null +++ b/apps/web/app/_components/Banner/index.tsx @@ -0,0 +1 @@ +export { Banner } from './Banner' diff --git a/apps/web/app/_components/BottomNavigation/BottomNavigation.tsx b/apps/web/app/_components/BottomNavigation/BottomNavigation.tsx new file mode 100644 index 0000000..2d1b133 --- /dev/null +++ b/apps/web/app/_components/BottomNavigation/BottomNavigation.tsx @@ -0,0 +1,30 @@ +import { TabItem, type TabItemProps } from './TabItem' +import { cn } from '@repo/ui/utils/cn' +import { JustifyBetween } from '@repo/ui/components/Layout' + +const tabs: TabItemProps[] = [ + { path: 'MAIN', label: '메인', icon: 'home' }, + { path: 'MAP', label: '주변 맛집', icon: 'map' }, + { path: 'PLACE_NEW', label: '', icon: 'circlePlus', iconSize: 50 }, + { path: 'LIKES', label: '찜', icon: 'navHeart' }, + { path: 'PROFILE', label: '내 정보', icon: 'navUser' }, +] + +export const BottomNavigation = () => { + return ( + + {tabs.map((tab: TabItemProps) => ( + + ))} + + ) +} diff --git a/apps/web/app/_components/BottomNavigation/TabItem.tsx b/apps/web/app/_components/BottomNavigation/TabItem.tsx new file mode 100644 index 0000000..08b8764 --- /dev/null +++ b/apps/web/app/_components/BottomNavigation/TabItem.tsx @@ -0,0 +1,42 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { CLIENT_PATH } from '@/_constants/path' +import { cn } from '@repo/ui/utils/cn' +import { Text } from '@repo/ui/components/Text' +import { Icon, IconType } from '@repo/ui/components/Icon' + +export type TabItemProps = { + path: keyof Pick< + typeof CLIENT_PATH, + 'MAIN' | 'MAP' | 'LIKES' | 'PROFILE' | 'PLACE_NEW' + > + icon: IconType + iconSize?: number + label?: string +} + +export const TabItem = ({ path, label, icon, iconSize = 26 }: TabItemProps) => { + const pathname = usePathname() + const href = CLIENT_PATH[path] + const active = pathname === href + + return ( + + + {label && ( + + {label} + + )} + + ) +} diff --git a/apps/web/app/_components/BottomNavigation/index.tsx b/apps/web/app/_components/BottomNavigation/index.tsx new file mode 100644 index 0000000..ae9b6be --- /dev/null +++ b/apps/web/app/_components/BottomNavigation/index.tsx @@ -0,0 +1 @@ +export { BottomNavigation } from './BottomNavigation' diff --git a/apps/web/app/_components/Categories/Categories.tsx b/apps/web/app/_components/Categories/Categories.tsx new file mode 100644 index 0000000..5954357 --- /dev/null +++ b/apps/web/app/_components/Categories/Categories.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useCategoryQueries } from '@/_apis/queries/category' +import { cn } from '@repo/ui/utils/cn' +import { CategoryItem } from './CategoryItem' + +export const Categories = () => { + const { data: categories } = useSuspenseQuery(useCategoryQueries.list()) + + return ( +
+ {categories.map((category) => ( + + ))} +
+ ) +} diff --git a/apps/web/app/_components/Categories/CategoryItem.tsx b/apps/web/app/_components/Categories/CategoryItem.tsx new file mode 100644 index 0000000..0d3d406 --- /dev/null +++ b/apps/web/app/_components/Categories/CategoryItem.tsx @@ -0,0 +1,23 @@ +import { CLIENT_PATH } from '@/_constants/path' +import { Category } from '@/_apis/schemas/category' +import { Icon } from '@repo/ui/components/Icon' +import { Text } from '@repo/ui/components/Text' +import { Column } from '@repo/ui/components/Layout' + +export const CategoryItem = ({ id, name, iconKey }: Category) => ( + + + + {name} + + +) diff --git a/apps/web/app/_components/Categories/index.tsx b/apps/web/app/_components/Categories/index.tsx new file mode 100644 index 0000000..fac3ddb --- /dev/null +++ b/apps/web/app/_components/Categories/index.tsx @@ -0,0 +1 @@ +export { Categories } from './Categories' diff --git a/apps/web/app/_components/PlaceListItem/PlaceListItem.tsx b/apps/web/app/_components/PlaceListItem/PlaceListItem.tsx new file mode 100644 index 0000000..8fdd968 --- /dev/null +++ b/apps/web/app/_components/PlaceListItem/PlaceListItem.tsx @@ -0,0 +1,45 @@ +import { Text } from '@repo/ui/components/Text' +import { Column, Flex } from '@repo/ui/components/Layout' +import { Icon } from '@repo/ui/components/Icon' +import { BasePlace } from '@/_apis/schemas/place' +import { Chip } from '@repo/ui/components/Chip' +import { cn } from '@repo/ui/utils/cn' + +type Props = { + showBorder?: boolean +} & BasePlace + +export const PlaceListItem = ({ + placeName, + address, + categories, + tags, + showBorder = true, +}: Props) => { + const mainCategoryIcon = categories[0]?.iconKey || 'logo' + + return ( + + {/*Todo: Link 태그로 감싸기 -> 상세페이지로 이동*/} + + {placeName} + + + + {address} + + {tags.length > 0 && ( + + {tags.map((tag) => ( + + ))} + + )} + + ) +} diff --git a/apps/web/app/_components/PlaceListItem/index.tsx b/apps/web/app/_components/PlaceListItem/index.tsx new file mode 100644 index 0000000..257da74 --- /dev/null +++ b/apps/web/app/_components/PlaceListItem/index.tsx @@ -0,0 +1 @@ +export { PlaceListItem } from './PlaceListItem' diff --git a/apps/web/app/_components/RankingPlaceList/MostLikedPlaces/MostLikedPlaces.tsx b/apps/web/app/_components/RankingPlaceList/MostLikedPlaces/MostLikedPlaces.tsx new file mode 100644 index 0000000..58221fe --- /dev/null +++ b/apps/web/app/_components/RankingPlaceList/MostLikedPlaces/MostLikedPlaces.tsx @@ -0,0 +1,13 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { usePlaceQueries } from '@/_apis/queries/place' +import { RankingPlaceList } from '@/_components/RankingPlaceList' + +export const MostLikedPlaces = () => { + const { data } = useSuspenseQuery(usePlaceQueries.rankingList('likes')) + + return ( + + ) +} diff --git a/apps/web/app/_components/RankingPlaceList/MostLikedPlaces/index.tsx b/apps/web/app/_components/RankingPlaceList/MostLikedPlaces/index.tsx new file mode 100644 index 0000000..7dd8614 --- /dev/null +++ b/apps/web/app/_components/RankingPlaceList/MostLikedPlaces/index.tsx @@ -0,0 +1 @@ +export { MostLikedPlaces } from './MostLikedPlaces' diff --git a/apps/web/app/_components/RankingPlaceList/MostViewsPlaces/MostViewsPlaces.tsx b/apps/web/app/_components/RankingPlaceList/MostViewsPlaces/MostViewsPlaces.tsx new file mode 100644 index 0000000..1d964e0 --- /dev/null +++ b/apps/web/app/_components/RankingPlaceList/MostViewsPlaces/MostViewsPlaces.tsx @@ -0,0 +1,11 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { usePlaceQueries } from '@/_apis/queries/place' +import { RankingPlaceList } from '@/_components/RankingPlaceList' + +export const MostViewsPlaces = () => { + const { data } = useSuspenseQuery(usePlaceQueries.rankingList('views')) + + return +} diff --git a/apps/web/app/_components/RankingPlaceList/MostViewsPlaces/index.tsx b/apps/web/app/_components/RankingPlaceList/MostViewsPlaces/index.tsx new file mode 100644 index 0000000..4bb7554 --- /dev/null +++ b/apps/web/app/_components/RankingPlaceList/MostViewsPlaces/index.tsx @@ -0,0 +1 @@ +export { MostViewsPlaces } from './MostViewsPlaces' diff --git a/apps/web/app/_components/RankingPlaceList/RankingPlaceList.tsx b/apps/web/app/_components/RankingPlaceList/RankingPlaceList.tsx new file mode 100644 index 0000000..6233739 --- /dev/null +++ b/apps/web/app/_components/RankingPlaceList/RankingPlaceList.tsx @@ -0,0 +1,28 @@ +import { Column } from '@repo/ui/components/Layout' +import { IconType } from '@repo/ui/components/Icon' +import { SubTitle } from '@/_components/SubTitle' +import { RankingPlace } from '@/_apis/schemas/place' +import { PlaceListItem } from '@/_components/PlaceListItem' + +type Props = { + title: string + icon: IconType + places: RankingPlace[] +} + +export const RankingPlaceList = ({ title, icon, places }: Props) => { + return ( + + +
    + {places.map((place, index) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/app/_components/RankingPlaceList/index.tsx b/apps/web/app/_components/RankingPlaceList/index.tsx new file mode 100644 index 0000000..2205676 --- /dev/null +++ b/apps/web/app/_components/RankingPlaceList/index.tsx @@ -0,0 +1,3 @@ +export { RankingPlaceList } from './RankingPlaceList' +export { MostViewsPlaces } from './MostViewsPlaces' +export { MostLikedPlaces } from './MostLikedPlaces' diff --git a/apps/web/app/_components/SubTitle/SubTitle.tsx b/apps/web/app/_components/SubTitle/SubTitle.tsx new file mode 100644 index 0000000..73137c8 --- /dev/null +++ b/apps/web/app/_components/SubTitle/SubTitle.tsx @@ -0,0 +1,17 @@ +import { Flex } from '@repo/ui/components/Layout' +import { Icon, IconType } from '@repo/ui/components/Icon' +import { Text } from '@repo/ui/components/Text' + +type Props = { + icon: IconType + title: string +} + +export const SubTitle = ({ icon, title }: Props) => ( + + + + {title} + + +) diff --git a/apps/web/app/_components/SubTitle/index.tsx b/apps/web/app/_components/SubTitle/index.tsx new file mode 100644 index 0000000..3cc0cc3 --- /dev/null +++ b/apps/web/app/_components/SubTitle/index.tsx @@ -0,0 +1 @@ +export { SubTitle } from './SubTitle' diff --git a/apps/web/app/_constants/path.ts b/apps/web/app/_constants/path.ts index a4a0fe9..933821b 100644 --- a/apps/web/app/_constants/path.ts +++ b/apps/web/app/_constants/path.ts @@ -1,3 +1,17 @@ +import { RankingPlaceSort } from '@/_apis/schemas/place' + export const API_PATH = { CATEGORY: '/categories', + RANKING: (sort: RankingPlaceSort) => `/places/ranking?sort=${sort}`, +} + +export const CLIENT_PATH = { + MAIN: '/', + MAP: '/map', + PLACE_NEW: '/places/new', + PLACE_SEARCH: '/places/search', + PLACE_DETAIL: (id: string | number) => `/places/${id}`, + CATEGORY_DETAIL: (id: string | number) => `/categories/${id}`, + LIKES: '/likes', + PROFILE: '/profile', } diff --git a/apps/web/app/_mocks/data/place.ts b/apps/web/app/_mocks/data/place.ts new file mode 100644 index 0000000..ca18748 --- /dev/null +++ b/apps/web/app/_mocks/data/place.ts @@ -0,0 +1,38 @@ +export const RankingPlaces = [ + { + placeId: 15, + placeName: '우돈탄 다산본점', + address: '경기 남양주시 다산중앙로82번길 25', + isLiked: true, + likeCount: 5, + categories: [ + { id: 3, name: '한식', iconKey: 'korean' }, + { id: 14, name: '고기·구이', iconKey: 'meat' }, + ], + tags: [ + { id: 2, name: '혼밥하기 좋은', iconKey: 'fingerUp' }, + { id: 5, name: '가성비 좋은', iconKey: 'calculator' }, + ], + }, + { + placeId: 21, + placeName: '김밥천국', + address: '서울특별시 강남구 테헤란로 100', + isLiked: false, + likeCount: 5, + categories: [ + { id: 4, name: '분식', iconKey: 'bunsik' }, + { id: 3, name: '한식', iconKey: 'korean' }, + ], + tags: [{ id: 7, name: '분위기 좋은', iconKey: 'blingBling' }], + }, + { + placeId: 2, + placeName: '짬뽕집', + address: '충남 천안시 서북구 테헤란로 100', + isLiked: false, + likeCount: 5, + categories: [{ id: 4, name: '중식', iconKey: 'chinese' }], + tags: [{ id: 7, name: '분위기 좋은', iconKey: 'blingBling' }], + }, +] diff --git a/apps/web/app/_mocks/handlers/index.ts b/apps/web/app/_mocks/handlers/index.ts index 31e9e0b..152e181 100644 --- a/apps/web/app/_mocks/handlers/index.ts +++ b/apps/web/app/_mocks/handlers/index.ts @@ -1,3 +1,4 @@ -import { CategoryHandlers } from '@/_mocks/handlers/categoryHandlers' +import { CategoryHandlers } from './categoryHandlers' +import { PlaceHandlers } from './placeHandlers' -export const handlers = [...CategoryHandlers] +export const handlers = [...CategoryHandlers, ...PlaceHandlers] diff --git a/apps/web/app/_mocks/handlers/placeHandlers.ts b/apps/web/app/_mocks/handlers/placeHandlers.ts new file mode 100644 index 0000000..1299b78 --- /dev/null +++ b/apps/web/app/_mocks/handlers/placeHandlers.ts @@ -0,0 +1,18 @@ +import { http, HttpResponse } from 'msw' +import { API_PATH } from '@/_constants/path' +import { RankingPlaces } from '../data/place' + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL || '' + +const addBaseUrl = (path: string) => { + return `${BASE_URL}${path}` +} + +export const PlaceHandlers = [ + http.get(addBaseUrl(API_PATH.RANKING('likes')), () => { + return HttpResponse.json(RankingPlaces) + }), + http.get(addBaseUrl(API_PATH.RANKING('views')), () => { + return HttpResponse.json(RankingPlaces) + }), +] diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 84b9428..0c52fbc 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import '@repo/ui/styles.css' import './globals.css' +import 'keen-slider/keen-slider.min.css' import type { Metadata } from 'next' import QueryProvider from './QueryClientProvider' import localFont from 'next/font/local' @@ -30,7 +31,7 @@ export default async function RootLayout({ -
+
{children} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index ec0ff95..5b9516e 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,13 +1,38 @@ -import { Flex } from '@repo/ui/components/Layout/Flex' +import { CLIENT_PATH } from '@/_constants/path' +import { useCategoryQueries } from '@/_apis/queries/category' +import { usePlaceQueries } from '@/_apis/queries/place' +import { HydrationBoundaryPage } from '@/HydrationBoundaryPage' +import { Categories } from '@/_components/Categories' +import { BottomNavigation } from '@/_components/BottomNavigation' +import { OnlyLeftHeader } from '@repo/ui/components/Header' +import { SearchBar } from '@repo/ui/components/SearchBar' +import { Column } from '@repo/ui/components/Layout' +import { Banner } from '@/_components/Banner' +import { + MostLikedPlaces, + MostViewsPlaces, +} from '@/_components/RankingPlaceList' +import { Divider } from '@repo/ui/components/Divider' export default function Page() { return ( - <> - -
gkldf
-
gkldf
-
gkldf
-
- + { + await queryClient.prefetchQuery(useCategoryQueries.list()) + await queryClient.prefetchQuery(usePlaceQueries.rankingList('likes')) + await queryClient.prefetchQuery(usePlaceQueries.rankingList('views')) + }} + > + + + + + + + + + + + ) } diff --git a/apps/web/package.json b/apps/web/package.json index 20f9d4c..c599a0e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,10 +21,12 @@ "@tanstack/react-query": "^5.84.1", "@tanstack/react-query-devtools": "^5.84.1", "axios": "^1.11.0", + "keen-slider": "^6.8.6", "motion": "^12.23.12", "next": "^15.4.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "zod": "^4.0.17", "zustand": "^5.0.7" }, "devDependencies": { diff --git a/packages/ui/src/components/Chip/Chip.stories.tsx b/packages/ui/src/components/Chip/Chip.stories.tsx index d57c3f1..94f1c25 100644 --- a/packages/ui/src/components/Chip/Chip.stories.tsx +++ b/packages/ui/src/components/Chip/Chip.stories.tsx @@ -1,6 +1,34 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import { Chip } from './Chip' import { Flex } from '../Layout' +import { IconType } from '../Icon' + +const CHIP_TAGS: { + id: number + label: string + icon: IconType +}[] = [ + { + id: 1, + label: '혼밥하기 좋은', + icon: 'fingerUp', + }, + { + id: 2, + label: '가성비 좋은', + icon: 'calculator', + }, + { + id: 3, + label: '분위기 좋은', + icon: 'blingBling', + }, + { + id: 4, + label: '친절해요', + icon: 'waiter', + }, +] const meta: Meta = { title: 'Components/Chip', @@ -14,10 +42,9 @@ type Story = StoryObj export const Default: Story = { render: () => ( - - - - + {CHIP_TAGS.map((category) => ( + + ))} ), } @@ -25,19 +52,13 @@ export const Default: Story = { export const ClickableChips: Story = { render: () => ( - {( - [ - 'SOLO_FRIENDLY', - 'VALUE_FOR_MONEY', - 'GOOD_AMBIENCE', - 'KIND_SERVICE', - ] as const - ).map((chipType) => ( + {CHIP_TAGS.map((category) => ( { - console.log(`${chipType} 클릭됨!`) + console.log(`${category.label} 클릭됨!`) }} /> ))} diff --git a/packages/ui/src/components/Chip/Chip.tsx b/packages/ui/src/components/Chip/Chip.tsx index 55bc627..fdba583 100644 --- a/packages/ui/src/components/Chip/Chip.tsx +++ b/packages/ui/src/components/Chip/Chip.tsx @@ -3,39 +3,19 @@ import type { ElementType, JSX, PropsWithChildren } from 'react' import type { PolymorphicComponentProps } from '../../polymorphics' import { useState } from 'react' import { cn } from '../../utils/cn' -import { Icon } from '../Icon' +import { Icon, type IconType } from '../Icon' import { Text } from '../Text' -const CHIP_TAGS = { - SOLO_FRIENDLY: { - label: '혼밥하기 좋은', - icon: 'fingerUp', - }, - VALUE_FOR_MONEY: { - label: '가성비 좋은', - icon: 'calculator', - }, - GOOD_AMBIENCE: { - label: '분위기 좋은', - icon: 'blingBling', - }, - KIND_SERVICE: { - label: '친절해요', - icon: 'waiter', - }, -} as const - -type ChipTagKey = keyof typeof CHIP_TAGS - export type ChipProps = PolymorphicComponentProps< C, { - chipType: ChipTagKey + icon: IconType + label: string onToggle?: () => void } > -export type ChipType = ( +export type ChipType = ( props: PropsWithChildren>, ) => JSX.Element @@ -46,28 +26,31 @@ export type ChipType = ( * - 클릭 시 내부 상태 `isActive`를 토글하며, `onToggle` 콜백을 실행합니다. * - 다양한 HTML 요소(`as` prop)를 지정하여 렌더링할 수 있습니다. * - * @template C 렌더링할 HTML 태그 타입 (기본값: 'button') + * @template C 렌더링할 HTML 태그 타입 (기본값: 'div') * * @param as 렌더링할 HTML 태그 또는 컴포넌트 * @param className 추가 CSS 클래스 - * @param chipType 표시할 Chip 타입 + * @param icon 표시할 아이콘 타입 + * @param label Chip에 표시할 텍스트 라벨 * @param onToggle 클릭 시 실행할 콜백 함수 * @param restProps 나머지 Props * * @returns 렌더링된 Chip 요소 * * @example - * console.log('클릭됨')} /> + * ```tsx + * console.log('클릭됨')} /> + * ``` */ export const Chip: ChipType = ({ as, className, - chipType, + icon, + label, onToggle, ...restProps }) => { - const Component = as || 'button' - const { icon, label } = CHIP_TAGS[chipType] + const Component = as || 'div' const [isActive, setIsActive] = useState(false) const onClick = () => { @@ -91,10 +74,10 @@ export const Chip: ChipType = ({ { 'ui:border-blue': isActive }, className, )} - onClick={onClick} {...restProps} + onClick={onClick} > - + {label} diff --git a/packages/ui/src/components/Divider/Divider.tsx b/packages/ui/src/components/Divider/Divider.tsx new file mode 100644 index 0000000..39a0ec8 --- /dev/null +++ b/packages/ui/src/components/Divider/Divider.tsx @@ -0,0 +1,5 @@ +import { cn } from '../../utils/cn' + +export const Divider = ({ className }: { className?: string }) => ( +
+) diff --git a/packages/ui/src/components/Divider/index.tsx b/packages/ui/src/components/Divider/index.tsx new file mode 100644 index 0000000..465ccc7 --- /dev/null +++ b/packages/ui/src/components/Divider/index.tsx @@ -0,0 +1 @@ +export { Divider } from './Divider' diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index bf0461d..914c6f2 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -29,6 +29,8 @@ export const OnlyLeftHeader = ({ }) => ( - {name} + + {name} + ) diff --git a/packages/ui/src/components/Icon/index.tsx b/packages/ui/src/components/Icon/index.tsx index d78603a..1f86e4b 100644 --- a/packages/ui/src/components/Icon/index.tsx +++ b/packages/ui/src/components/Icon/index.tsx @@ -1 +1,2 @@ export { Icon } from './Icon' +export type { IconType } from './IconMap' diff --git a/packages/ui/src/components/SearchBar/SearchBar.tsx b/packages/ui/src/components/SearchBar/SearchBar.tsx index 0ec8bbe..dfe6b4b 100644 --- a/packages/ui/src/components/SearchBar/SearchBar.tsx +++ b/packages/ui/src/components/SearchBar/SearchBar.tsx @@ -3,7 +3,13 @@ import { Flex } from '../Layout' import { cn } from '../../utils/cn' import { Text } from '../Text' -export const SearchBar = ({ href }: { href: string }) => { +export const SearchBar = ({ + href, + className, +}: { + href: string + className?: string +}) => { return ( { 'ui:p-3.5', 'ui:items-center', 'ui:gap-2', + className, )} aria-label={'검색 페이지로 이동'} > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a93304..ebff357 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: axios: specifier: ^1.11.0 version: 1.11.0 + keen-slider: + specifier: ^6.8.6 + version: 6.8.6 motion: specifier: ^12.23.12 version: 12.23.12(react-dom@19.1.0)(react@19.1.0) @@ -141,6 +144,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + zod: + specifier: ^4.0.17 + version: 4.0.17 zustand: specifier: ^5.0.7 version: 5.0.7(@types/react@19.1.0)(react@19.1.0) @@ -7532,6 +7538,10 @@ packages: object.values: 1.2.1 dev: true + /keen-slider@6.8.6: + resolution: {integrity: sha512-dcEQ7GDBpCjUQA8XZeWh3oBBLLmyn8aoeIQFGL/NTVkoEOsmlnXqA4QykUm/SncolAZYGsEk/PfUhLZ7mwMM2w==} + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -10747,6 +10757,10 @@ packages: engines: {node: '>=18'} dev: true + /zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + dev: false + /zustand@5.0.7(@types/react@19.1.0)(react@19.1.0): resolution: {integrity: sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==} engines: {node: '>=12.20.0'}