Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/app/_components/PlaceListItem/PlaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Text } from '@repo/ui/components/Text'
import { Icon } from '@repo/ui/components/Icon'
import { Chip } from '@repo/ui/components/Chip'
import { Column, Flex } from '@repo/ui/components/Layout'
import { PlaceListItemSkeleton } from './PlaceListItemSkeleton'

type Props = {
showCategory?: boolean
Expand Down Expand Up @@ -53,3 +54,5 @@ export const PlaceListItem = ({
</li>
)
}

PlaceListItem.Skeleton = PlaceListItemSkeleton
29 changes: 29 additions & 0 deletions apps/web/app/_components/PlaceListItem/PlaceListItemSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client'

import { Skeleton } from '@heroui/react'
import { Column, Flex } from '@repo/ui/components/Layout'
import { cn } from '@repo/ui/utils/cn'

type Props = {
count?: number
}

export const PlaceListItemSkeleton = ({ count = 3 }: Props) => {
return (
<div className={cn('w-full', 'flex flex-col gap-4')}>
{Array.from({ length: count }).map((_, index) => (
<Column
key={index}
className='border-b-1 w-full gap-3 border-gray-50 pb-4 pt-2.5'
>
<Skeleton className='w-18 h-5 rounded-full' />
<Skeleton className='w-26 h-4 rounded-full' />
<Flex className={'gap-1'}>
<Skeleton className='w-18 h-6 rounded-full' />
<Skeleton className='w-18 h-6 rounded-full' />
</Flex>
</Column>
))}
</div>
)
}

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

47 changes: 35 additions & 12 deletions apps/web/app/_components/RankingPlaceList/RankingPlaceList.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,51 @@
'use client'

import { Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCampusStore } from '@/_store/campus'
import { usePlaceQueries } from '@/_apis/queries/place'
import type { IconType } from '@repo/ui/components/Icon'
import type { BasePlace } from '@/_apis/schemas/place'
import type { RankingPlaceSort } from '@/_apis/schemas/place'
import { Column } from '@repo/ui/components/Layout'
import { SubTitle } from '@/_components/SubTitle'
import { PlaceListItem } from '@/_components/PlaceListItem'

type Props = {
title: string
icon: IconType
places: BasePlace[]
rankingPlaceSort: RankingPlaceSort
}

export const RankingPlaceList = ({ title, icon, places }: Props) => {
export const RankingPlaceList = ({ title, icon, rankingPlaceSort }: Props) => {
return (
<Column className={'gap-1.5 px-5'}>
<SubTitle title={title} icon={icon} />
<ul className={'px-3'}>
{places.map((place, index) => (
<PlaceListItem
key={place.placeId}
{...place}
showBorder={index !== places.length - 1}
/>
))}
</ul>
<Suspense fallback={<PlaceListItem.Skeleton />}>
<PlaceList rankingPlaceSort={rankingPlaceSort} />
</Suspense>
</Column>
)
}

const PlaceList = ({
rankingPlaceSort,
}: {
rankingPlaceSort: RankingPlaceSort
}) => {
const { campus } = useCampusStore()
const { data: places } = useSuspenseQuery(
usePlaceQueries.byRanking(rankingPlaceSort, campus),
)

return (
<ul className={'px-3'}>
{places.map((place, index) => (
<PlaceListItem
key={place.placeId}
{...place}
showBorder={index !== places.length - 1}
/>
))}
</ul>
)
}
2 changes: 0 additions & 2 deletions apps/web/app/_components/RankingPlaceList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export { RankingPlaceList } from './RankingPlaceList'
export { MostViewsPlaces } from './MostViewsPlaces'
export { MostLikesPlaces } from './MostLikesPlaces'
30 changes: 14 additions & 16 deletions apps/web/app/categories/[id]/CategoryDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
'use client'

import { usePathname } from 'next/navigation'
import { Suspense, useEffect } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCategoryQueries } from '@/_apis/queries/category'
import { useCategoryIdFromUrl } from './_hooks/useCategoryIdFromUrl'

import { Icon } from '@repo/ui/components/Icon'
import { Text } from '@repo/ui/components/Text'
import { Header } from '@repo/ui/components/Header'
import { Flex } from '@repo/ui/components/Layout'
import { HeaderBackButton } from '@/_components/HeaderBackButton'
import { PlaceListItem } from '@/_components/PlaceListItem'
import { RowCategories, Places } from './_components'
import { Suspense, useEffect } from 'react'
import { Spinner } from '@heroui/react'
import { SwipeableArea } from './_components/SwipeableArea'

export const CategoryDetailPage = () => {
const { data: categories } = useSuspenseQuery(useCategoryQueries.list())
const activeCategoryId = usePathname().split('/')[2] || '0'
const [categoryId, setCategoryId] = useCategoryIdFromUrl()
const activeCategory = categories.find(
(category) => category.id === activeCategoryId,
(category) => category.id === categoryId,
)
Comment on lines +18 to 21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

activeCategory 계산 시 숫자/문자열 비교로 매칭이 항상 실패합니다.

useCategoryIdFromUrl가 문자열을 반환하는 반면, 카테고리 데이터의 id는 mock 기준 number입니다. 현재처럼 category.id === categoryId로 strict 비교하면 어떤 카테고리도 매칭되지 않아 activeCategory가 항상 undefined가 됩니다(문서 제목과 헤더 아이콘/이름 모두 깨짐).

문자열로 맞춰 비교하도록 수정하는 쪽이 안전해 보입니다.

-  const activeCategory = categories.find(
-    (category) => category.id === categoryId,
-  )
+  const activeCategory = categories.find(
+    (category) => String(category.id) === categoryId,
+  )
🤖 Prompt for AI Agents
In apps/web/app/categories/[id]/CategoryDetailPage.tsx around lines 18 to 21,
activeCategory is never matched because useCategoryIdFromUrl() returns a string
while category.id in mocks is a number; change the comparison to compare string
forms (e.g., convert category.id to String() before ===) or coerce categoryId to
a number before comparison so the types match; update the equality to use the
same type (string-to-string or number-to-number) and add a small comment
explaining the conversion.


const setIdFunc = (id: string) => {
window.history.replaceState(null, '', `/categories/${id}`)
}

useEffect(() => {
document.title = `공주대 맛집 | ${activeCategory?.name}`
}, [activeCategory])
Expand All @@ -42,14 +39,15 @@ export const CategoryDetailPage = () => {
className={'border-b-1 border-gray-50'}
/>
<RowCategories
id={activeCategoryId}
categories={categories}
setIdFunc={setIdFunc}
categoryId={categoryId}
setCategoryId={setCategoryId}
/>
{/*Todo: 맛집 리스트 스켈레톤으로 변경하기*/}
<Suspense fallback={<Spinner className={'my-auto'} />}>
<Places id={activeCategoryId} setIdFunc={setIdFunc} />
</Suspense>
<SwipeableArea categoryId={categoryId} setCategoryId={setCategoryId}>
<Suspense fallback={<PlaceListItem.Skeleton count={3} />}>
<Places categoryId={categoryId} />
</Suspense>
</SwipeableArea>
</>
)
}
76 changes: 19 additions & 57 deletions apps/web/app/categories/[id]/_components/Places/Places.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,36 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { motion, PanInfo } from 'motion/react'
import { useCampusStore } from '@/_store/campus'
import { usePlaceQueries } from '@/_apis/queries/place'
import { PlaceListItem } from '@/_components/PlaceListItem'
import { VerticalScrollArea } from '@repo/ui/components/Layout'
import { EmptyPlaces } from './EmptyPlaces'

type Props = {
id: string
setIdFunc: (id: string) => void
categoryId: string
}

// 스와이프 감도
const SWIPE_CONFIDENCE_THRESHOLD = 20

export const Places = ({ id, setIdFunc }: Props) => {
export const Places = ({ categoryId }: Props) => {
const { campus } = useCampusStore()
const { data: places } = useSuspenseQuery(
usePlaceQueries.byCategory(id, campus),
usePlaceQueries.byCategory(categoryId, campus),
)

const currentCategoryId = Number(id)
const onDragEnd = (
_e: MouseEvent | TouchEvent | PointerEvent,
{ offset, velocity }: PanInfo,
) => {
const swipePower = Math.abs(offset.x) * velocity.x

if (swipePower < -SWIPE_CONFIDENCE_THRESHOLD) {
if (currentCategoryId < 15) {
setIdFunc(String(currentCategoryId + 1))
}
} else if (swipePower > SWIPE_CONFIDENCE_THRESHOLD) {
if (currentCategoryId > 1) {
setIdFunc(String(currentCategoryId - 1))
}
}
}

const content =
places.length === 0 ? (
<EmptyPlaces />
) : (
<VerticalScrollArea as={'ul'} className={'px-8'}>
{places.map((place, index) => (
<PlaceListItem
key={place.placeId}
{...place}
showCategory={false}
showBorder={index !== places.length - 1}
/>
))}
</VerticalScrollArea>
)

return (
<div className='relative h-full w-full overflow-hidden'>
<motion.div
key={id}
drag='x'
dragConstraints={{
right: currentCategoryId <= 1 ? 0 : undefined,
left: currentCategoryId >= 15 ? 0 : undefined,
}}
dragElastic={0.2}
onDragEnd={onDragEnd}
className='relative h-full w-full bg-white'
>
{content}
</motion.div>
</div>
<>
{places.length === 0 ? (
<EmptyPlaces />
) : (
<VerticalScrollArea as={'ul'}>
{places.map((place, index) => (
<PlaceListItem
key={place.placeId}
{...place}
showCategory={false}
showBorder={index !== places.length - 1}
/>
))}
</VerticalScrollArea>
)}
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@ import { cn } from '@repo/ui/utils/cn'

type Props = {
categories: Category[]
id: string
setIdFunc: (id: string) => void
categoryId: string
setCategoryId: (id: string) => void
}

export const RowCategories = ({ id, categories, setIdFunc }: Props) => {
export const RowCategories = ({
categoryId,
categories,
setCategoryId,
}: Props) => {
return (
<div className={'relative px-5 py-2.5'}>
<Flex className={'scrollbar-hide gap-4 overflow-x-auto'}>
{categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
isActive={id === category.id}
isActive={categoryId === category.id}
onClick={() => {
setIdFunc(category.id)
setCategoryId(category.id)
}}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { motion, PanInfo } from 'motion/react'

type Props = {
categoryId: string
setCategoryId: (id: string) => void
children: React.ReactNode
}

const SWIPE_CONFIDENCE_THRESHOLD = 20

export const SwipeableArea = ({
categoryId,
setCategoryId,
children,
}: Props) => {
const NumberToCategoryId = Number(categoryId)

const onDragEnd = (
_e: MouseEvent | TouchEvent | PointerEvent,
{ offset, velocity }: PanInfo,
) => {
const swipePower = Math.abs(offset.x) * velocity.x

if (swipePower < -SWIPE_CONFIDENCE_THRESHOLD) {
if (NumberToCategoryId < 15) {
setCategoryId(String(NumberToCategoryId + 1))
}
} else if (swipePower > SWIPE_CONFIDENCE_THRESHOLD) {
if (NumberToCategoryId > 1) {
setCategoryId(String(NumberToCategoryId - 1))
}
}
}

return (
<div className='relative h-full w-full overflow-hidden px-8'>
<motion.div
key={categoryId}
drag='x'
dragConstraints={{
right: NumberToCategoryId <= 1 ? 0 : undefined,
left: NumberToCategoryId >= 15 ? 0 : undefined,
}}
dragElastic={0.2}
onDragEnd={onDragEnd}
className='relative h-full w-full bg-white'
>
{children}
</motion.div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SwipeableArea } from './SwipeableArea'
11 changes: 11 additions & 0 deletions apps/web/app/categories/[id]/_hooks/useCategoryIdFromUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { usePathname } from 'next/navigation'
import { CLIENT_PATH } from '@/_constants/path'

export const useCategoryIdFromUrl = () => {
const categoryId = usePathname().split('/')[2] || '0'
const setCategoryId = (id: string) => {
window.history.replaceState(null, '', CLIENT_PATH.CATEGORY_DETAIL(id))
}

return [categoryId, setCategoryId] as const
}
Comment on lines +1 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find usages of useCategoryIdFromUrl hook
rg "useCategoryIdFromUrl" --type ts --type tsx -B 2 -A 2

Repository: knu-matzip/Matzip-Client

Length of output: 95


🏁 Script executed:

# Find SwipeableArea and RowCategories components mentioned in the review
fd "SwipeableArea|RowCategories" --type f -e ts -e tsx

Repository: knu-matzip/Matzip-Client

Length of output: 213


🏁 Script executed:

# Check the actual file content and its context
cat -n "apps/web/app/categories/[id]/_hooks/useCategoryIdFromUrl.ts"

Repository: knu-matzip/Matzip-Client

Length of output: 509


🏁 Script executed:

# Search for setCategoryId usage specifically
rg "setCategoryId" --type ts --type tsx -B 3 -A 3

Repository: knu-matzip/Matzip-Client

Length of output: 95


🏁 Script executed:

# Correct search for useCategoryIdFromUrl usage with proper file types
rg "useCategoryIdFromUrl" --type ts --type js -B 2 -A 5

Repository: knu-matzip/Matzip-Client

Length of output: 2334


🏁 Script executed:

# Read RowCategories component
cat -n "apps/web/app/categories/[id]/_components/RowCategories/RowCategories.tsx"

Repository: knu-matzip/Matzip-Client

Length of output: 1487


🏁 Script executed:

# Read SwipeableArea component
cat -n "apps/web/app/categories/[id]/_components/SwipeableArea/SwipeableArea.tsx"

Repository: knu-matzip/Matzip-Client

Length of output: 1713


🏁 Script executed:

# Read full CategoryDetailPage.tsx to see the useEffect
cat -n "apps/web/app/categories/[id]/CategoryDetailPage.tsx"

Repository: knu-matzip/Matzip-Client

Length of output: 2258


🏁 Script executed:

# Search for router.replace usage patterns in the codebase
rg "router\.replace" --type ts --type js -B 2 -A 2

Repository: knu-matzip/Matzip-Client

Length of output: 50


🏁 Script executed:

# Check if there's any useEffect with pathname dependency in CategoryDetailPage or similar files
rg "useEffect.*pathname|pathname.*useEffect" --type ts --type js -B 3 -A 5

Repository: knu-matzip/Matzip-Client

Length of output: 50


🏁 Script executed:

# Double-check: search for useRouter import to see if router is used elsewhere in the app
rg "useRouter" --type ts --type js -l | head -20

Repository: knu-matzip/Matzip-Client

Length of output: 389


🏁 Script executed:

# Verify CLIENT_PATH.CATEGORY_DETAIL function to understand the path format
rg "CATEGORY_DETAIL" --type ts --type js -B 2 -A 2

Repository: knu-matzip/Matzip-Client

Length of output: 1800


Use router.replace() instead of window.history.replaceState to ensure usePathname updates

The hook computes categoryId from usePathname() once at render time, but setCategoryId only calls window.history.replaceState, which doesn't trigger Next.js router navigation. This means:

  • URL updates in the browser but usePathname() doesn't re-evaluate
  • categoryId remains stale
  • SwipeableArea's key={categoryId} doesn't update, so the motion animation doesn't re-mount
  • Components receive outdated categoryId values

When SwipeableArea or RowCategories call setCategoryId, the UI won't reflect the change. Using router.replace() instead triggers proper router navigation, making usePathname() re-run and the component re-render with the correct category ID.

-import { usePathname } from 'next/navigation'
+import { usePathname, useRouter } from 'next/navigation'
 import { CLIENT_PATH } from '@/_constants/path'
 
 export const useCategoryIdFromUrl = () => {
+  const router = useRouter()
   const categoryId = usePathname().split('/')[2] || '0'
   const setCategoryId = (id: string) => {
-    window.history.replaceState(null, '', CLIENT_PATH.CATEGORY_DETAIL(id))
+    router.replace(CLIENT_PATH.CATEGORY_DETAIL(id))
   }
 
   return [categoryId, setCategoryId] as const
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { usePathname } from 'next/navigation'
import { CLIENT_PATH } from '@/_constants/path'
export const useCategoryIdFromUrl = () => {
const categoryId = usePathname().split('/')[2] || '0'
const setCategoryId = (id: string) => {
window.history.replaceState(null, '', CLIENT_PATH.CATEGORY_DETAIL(id))
}
return [categoryId, setCategoryId] as const
}
import { usePathname, useRouter } from 'next/navigation'
import { CLIENT_PATH } from '@/_constants/path'
export const useCategoryIdFromUrl = () => {
const router = useRouter()
const categoryId = usePathname().split('/')[2] || '0'
const setCategoryId = (id: string) => {
router.replace(CLIENT_PATH.CATEGORY_DETAIL(id))
}
return [categoryId, setCategoryId] as const
}
🤖 Prompt for AI Agents
In apps/web/app/categories/[id]/_hooks/useCategoryIdFromUrl.ts lines 1-11, the
hook currently uses window.history.replaceState which does not trigger Next.js
navigation so usePathname() never updates; replace that call by importing and
using the app-router's replace API (import { useRouter } from
'next/navigation'), call router.replace(CLIENT_PATH.CATEGORY_DETAIL(id)) inside
setCategoryId, and get router via const router = useRouter(); keep returning
[categoryId, setCategoryId] as const so usePathname re-evaluates and components
remount correctly.

Loading