-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/#12 공통 바텀 네비게이션 및 메인 화면 ui 구현 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0fa1c3c
d8f9d98
cea3ddc
8e99bd1
45bd18d
359ed03
33bd5ea
ecb34f1
3751ed0
5550d75
479abb2
c70d5f9
1ee4782
33ff257
991540b
c00e778
03a8dda
e72bfca
746f1b4
511b92f
fd8f740
0321ce6
3a899cc
56ee87e
f965101
2fa5401
381ec61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }), | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| }), | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof CategorySchema> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof BasePlaceSchema> | ||
| export type RankingPlace = z.infer<typeof RankingPlaceSchema> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Category[]> => { | ||
| const { data } = await axiosInstance.get(API_PATH.CATEGORY) | ||
| return CategorySchema.array().parse(data) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RankingPlace[]> => { | ||
| const { data } = await axiosInstance.get(API_PATH.RANKING(sort)) | ||
| return RankingPlaceSchema.array().parse(data) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| * <Banner | ||
| * contents={[ | ||
| * <div>배너 1</div>, | ||
| * <div>배너 2</div>, | ||
| * <div>배너 3</div>, | ||
| * ]} | ||
| * minHeight={200} | ||
| * /> | ||
| * ``` | ||
| */ | ||
| export const Banner = ({ contents, minHeight = 150 }: Props) => { | ||
| const [sliderRef] = useKeenSlider<HTMLDivElement>( | ||
| { | ||
| loop: true, | ||
| }, | ||
| [ | ||
| (slider) => { | ||
| let timeout: ReturnType<typeof setTimeout> | ||
| 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() | ||
|
Comment on lines
+40
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 타이머 초기화 안전성 및 이벤트 리스너 정리 누락 — 메모리 누수/경합 방지 리팩터 권장
아래와 같이 보완을 제안합니다. - let timeout: ReturnType<typeof setTimeout>
+ let timeout: ReturnType<typeof setTimeout> | null = null
let mouseOver = false
function clearNextTimeout() {
- clearTimeout(timeout)
+ if (timeout !== null) {
+ clearTimeout(timeout)
+ timeout = null
+ }
}
function nextTimeout() {
- clearTimeout(timeout)
+ clearNextTimeout()
if (mouseOver) return
- timeout = setTimeout(() => {
+ timeout = setTimeout(() => {
slider.next()
}, 2000)
}
- slider.on('created', () => {
- slider.container.addEventListener('mouseover', () => {
- mouseOver = true
- clearNextTimeout()
- })
- slider.container.addEventListener('mouseout', () => {
- mouseOver = false
- nextTimeout()
- })
- nextTimeout()
- })
+ const onMouseEnter = () => {
+ mouseOver = true
+ clearNextTimeout()
+ }
+ const onMouseLeave = () => {
+ mouseOver = false
+ nextTimeout()
+ }
+ slider.on('created', () => {
+ slider.container.addEventListener('mouseenter', onMouseEnter)
+ slider.container.addEventListener('mouseleave', onMouseLeave)
+ nextTimeout()
+ })
slider.on('dragStarted', clearNextTimeout)
slider.on('animationEnded', nextTimeout)
slider.on('updated', nextTimeout)
+ slider.on('destroyed', () => {
+ slider.container.removeEventListener('mouseenter', onMouseEnter)
+ slider.container.removeEventListener('mouseleave', onMouseLeave)
+ clearNextTimeout()
+ })Also applies to: 63-66 🤖 Prompt for AI Agents |
||
| }) | ||
| slider.on('dragStarted', clearNextTimeout) | ||
| slider.on('animationEnded', nextTimeout) | ||
| slider.on('updated', nextTimeout) | ||
| }, | ||
| ], | ||
| ) | ||
|
|
||
| return ( | ||
| <div ref={sliderRef} className={'keen-slider'} style={{ minHeight }}> | ||
| {contents.map((content, index) => ( | ||
| <div key={index} className='keen-slider__slide bg-gray-100'> | ||
| {content} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Banner } from './Banner' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <JustifyBetween | ||
| as={'nav'} | ||
| className={cn( | ||
| 'border-t-1', | ||
| 'border-gray-50', | ||
| 'h-15', | ||
| 'mt-auto', | ||
| 'px-5 py-2.5', | ||
| )} | ||
| > | ||
| {tabs.map((tab: TabItemProps) => ( | ||
| <TabItem key={tab.path} {...tab} /> | ||
| ))} | ||
| </JustifyBetween> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Link href={href} className={'w-12.5 flex flex-col items-center'}> | ||
| <Icon | ||
| type={icon} | ||
| size={iconSize} | ||
| color={active ? '--color-main' : '--color-gray-200'} | ||
| /> | ||
|
Comment on lines
+27
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainIcon color에 CSS 변수를 직접 전달할 때의 처리 로직 확인 필요 현재 레포에서 Icon 컴포넌트의 color 처리 로직을 확인하는 스크립트: 🏁 Script executed: #!/bin/bash
set -e
echo "Icon 컴포넌트에서 color 처리 로직 탐색"
rg -n -C2 "export.*Icon(?!List)" packages/ui/src/components/Icon || true
rg -n -C2 "color\??:\s*string" packages/ui/src/components/Icon || true
rg -n -C2 "var\\(--" packages/ui/src/components/Icon || true
echo "아이콘 에셋에서 color prop 사용 패턴 탐색(fill={color})"
rg -n -C2 "fill=\{color" packages/ui/src/components/Icon/assets/icons || trueLength of output: 12403 Icon 컴포넌트가 CSS 변수 문자열을 처리하지 않으므로
위 두 가지 중 하나를 선택해 적용해주세요. 🤖 Prompt for AI Agents |
||
| {label && ( | ||
| <Text | ||
| fontWeight={'normal'} | ||
| className={cn('text-[10px]', active ? 'text-main' : 'text-gray-200')} | ||
| > | ||
| {label} | ||
| </Text> | ||
| )} | ||
| </Link> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { BottomNavigation } from './BottomNavigation' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div | ||
| className={cn('grid grid-flow-row grid-cols-5 gap-y-6', 'px-7.5 py-2.5')} | ||
| > | ||
| {categories.map((category) => ( | ||
| <CategoryItem key={category.id} {...category} /> | ||
| ))} | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) => ( | ||
| <Column | ||
| as={'a'} | ||
| href={CLIENT_PATH.CATEGORY_DETAIL(id)} | ||
| className={'items-center gap-1'} | ||
| > | ||
| <Icon type={iconKey} size={40} /> | ||
| <Text | ||
| as={'span'} | ||
| fontSize={'sm'} | ||
| fontWeight={'semibold'} | ||
| className={'text-main text-nowrap'} | ||
| > | ||
| {name} | ||
| </Text> | ||
| </Column> | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Categories } from './Categories' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
z.enum에 동적 배열(IconList) 전달은 TS 타입 오류 발생 가능성이 큽니다
IconList는string[]으로 추론되어z.enum의 튜플 요구사항을 만족하지 못합니다. 현재 구현은 빌드 타임에 TS 에러가 날 가능성이 높습니다.아래처럼
z.custom<IconType>로 검증을 수행하면 런타임 검증과 타입 추론(IconType)을 동시에 만족합니다.참고 대안:
IconList를readonly [IconType, ...IconType[]]튜플로 보장하도록 내보내면z.enum(IconList)사용이 가능합니다. 다만 현재Object.keys기반 구현에서는 튜플 보장이 어려워 위 방식이 안전합니다.📝 Committable suggestion
🤖 Prompt for AI Agents