From 7f832cf0baf9abc13b0b6646af18d6b1985ba183 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Sun, 17 May 2026 23:32:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20UI=20=EC=9D=BC=EA=B4=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#51,=20#52,=20#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드는 지원하지만 프론트에서 노출되지 않던 기능들을 추가하고, 가로 스크롤 UX 개선을 위한 드래그 스크롤을 도입. - 빈자리 알림 시간대 선택 UI 추가 (#51) 매장 상세에서 시간 슬롯 상태에 따라 진입 버튼/모달 헤더를 3-way로 분기, 만석 슬롯에 점선 테두리 + 종 아이콘 적용 - 카테고리 페이지 지역 필터 드롭다운 추가 (#52) 25개 서울 자치구 드롭다운 추가, 카테고리와 독립 동작, URL 파라미터 동기화 - 가로 드래그 스크롤 기능 추가 (#53) useDragScroll 커스텀 훅으로 마우스 클릭+드래그 스크롤 지원, 카테고리 탭과 14일 날짜 스트립에 적용 --- src/app/category/page.tsx | 104 ++++++++++++++++++++++++++++++++--- src/app/stores/[id]/page.tsx | 38 ++++++++++--- src/hooks/useDragScroll.ts | 68 +++++++++++++++++++++++ 3 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useDragScroll.ts diff --git a/src/app/category/page.tsx b/src/app/category/page.tsx index 2655824..2d60c84 100644 --- a/src/app/category/page.tsx +++ b/src/app/category/page.tsx @@ -1,17 +1,20 @@ 'use client'; -import { Suspense, useEffect, useRef } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { ChevronDown, Check } from 'lucide-react'; import Header from '@/components/common/Header'; import BottomNav from '@/components/common/BottomNav'; import StoreCard from '@/components/store/StoreCard'; import { useStoresInfiniteQuery } from '@/lib/storeQuery'; -import { STORE_CATEGORIES } from '@/lib/storeEnum'; +import { STORE_CATEGORIES, STORE_DISTRICTS, toDistrictLabel } from '@/lib/storeEnum'; +import { useDragScroll } from '@/hooks/useDragScroll'; function CategoryContent() { const router = useRouter(); const searchParams = useSearchParams(); const selectedCategory = searchParams.get('selected'); // 백엔드 enum 값 + const selectedDistrict = searchParams.get('district'); const { data, @@ -20,7 +23,10 @@ function CategoryContent() { hasNextPage, isFetchingNextPage, } = useStoresInfiniteQuery( - { category: selectedCategory ?? undefined }, + { + category: selectedCategory ?? undefined, + district: selectedDistrict ?? undefined, + }, 10, ); @@ -42,20 +48,51 @@ function CategoryContent() { return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + const buildHref = (category: string | null, district: string | null) => { + const params = new URLSearchParams(); + if (category) params.set('selected', category); + if (district) params.set('district', district); + const qs = params.toString(); + return qs ? `/category?${qs}` : '/category'; + }; + const handleCategoryClick = (categoryEnum: string | null) => { - if (categoryEnum) { - router.push(`/category?selected=${categoryEnum}`); - } else { - router.push('/category'); - } + router.push(buildHref(categoryEnum, selectedDistrict)); }; + const handleDistrictClick = (districtEnum: string | null) => { + router.push(buildHref(selectedCategory, districtEnum)); + setIsDistrictOpen(false); + }; + + const categoryScrollRef = useDragScroll(); + + const [isDistrictOpen, setIsDistrictOpen] = useState(false); + const districtDropdownRef = useRef(null); + + useEffect(() => { + if (!isDistrictOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + districtDropdownRef.current && + !districtDropdownRef.current.contains(e.target as Node) + ) { + setIsDistrictOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isDistrictOpen]); + return ( <>
{/* 카테고리 탭 */} -
+
+ {/* 지역 필터 (드롭다운) */} +
+
+ + + {isDistrictOpen && ( +
+ + {STORE_DISTRICTS.map((district) => { + const isActive = selectedDistrict === district.enumValue; + return ( + + ); + })} +
+ )} +
+
+ {/* 매장 목록 */}
{isLoading ? ( diff --git a/src/app/stores/[id]/page.tsx b/src/app/stores/[id]/page.tsx index aa4e184..7a660c8 100644 --- a/src/app/stores/[id]/page.tsx +++ b/src/app/stores/[id]/page.tsx @@ -2,7 +2,7 @@ import { useState, Fragment } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { Star, Clock, MapPin, Heart, Check, Plus } from 'lucide-react'; +import { Star, Clock, MapPin, Heart, Check, Plus, Bell } from 'lucide-react'; import { DayPicker } from 'react-day-picker'; import { ko } from 'react-day-picker/locale'; import 'react-day-picker/style.css'; @@ -15,6 +15,7 @@ import FolderFormModal from '@/components/common/FolderFormModal'; import type { StoreRemain } from '@/types/store'; import { useStoreDetailQuery, useStoreMenusQuery, useStoreReviewsQuery, useStoreTimesQuery } from '@/lib/storeQuery'; import { useCreateVacancyMutation } from '@/lib/vacancyQuery'; +import { useDragScroll } from '@/hooks/useDragScroll'; import { useBookmarkFoldersQuery, useCreateBookmarkFolderMutation, @@ -49,6 +50,7 @@ export default function StoreDetail() { const { data: menus = [], isLoading: isMenuLoading } = useStoreMenusQuery(id); const { data: reviews = [], isLoading: isReviewLoading } = useStoreReviewsQuery(id); const { mutate: createVacancy, isPending: isVacancyPending } = useCreateVacancyMutation(); + const dateStripRef = useDragScroll(); const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedTime, setSelectedTime] = useState(null); const [selectedRemainId, setSelectedRemainId] = useState(null); @@ -101,6 +103,17 @@ export default function StoreDetail() { ); const isSelectedFullyBooked = fullyBookedDates.has(selectedDate.toDateString()); + const hasAvailableSlot = times.some((t) => t.remainTeam > 0); + const hasFullSlot = times.some((t) => t.remainTeam <= 0); + const slotState: 'mixed' | 'reserve' | 'vacancy' | 'unknown' = + times.length === 0 + ? 'unknown' + : hasAvailableSlot && hasFullSlot + ? 'mixed' + : hasAvailableSlot + ? 'reserve' + : 'vacancy'; + if (!id || isLoading) { return ( @@ -235,7 +248,7 @@ export default function StoreDetail() { 휴업중인 매장은 예약이 불가합니다.

) : ( -
+
{days.map((date) => { const { month, day, weekday } = formatDateParts(date); const isToday = @@ -396,7 +409,13 @@ export default function StoreDetail() { isSelectedFullyBooked ? 'bg-blue-500 hover:bg-blue-600' : 'bg-orange-500 hover:bg-orange-600' }`} > - {isSelectedFullyBooked ? '시간대 선택하고 빈자리 알림 받기' : '예약하기'} + {slotState === 'unknown' + ? (isSelectedFullyBooked ? '시간대 선택하고 빈자리 알림 받기' : '예약하기') + : slotState === 'mixed' + ? '예약 또는 빈자리 알림' + : slotState === 'vacancy' + ? '시간대 선택하고 빈자리 알림 받기' + : '예약하기'} )}
@@ -442,8 +461,12 @@ export default function StoreDetail() { {/* 시간 선택 */}

- {selectedDate.getMonth() + 1}월 {selectedDate.getDate()}일 예약 - 가능 시간 + {selectedDate.getMonth() + 1}월 {selectedDate.getDate()}일{' '} + {slotState === 'mixed' + ? '예약 또는 빈자리 알림' + : slotState === 'vacancy' + ? '빈자리 알림 받을 시간' + : '예약 가능 시간'}

{isTimesLoading ? ( @@ -462,14 +485,15 @@ export default function StoreDetail() { setSelectedTimeIsFull(isFull); } }} - className={`rounded-lg border py-2 text-sm font-medium ${ + className={`flex items-center justify-center gap-1 rounded-lg border py-2 text-sm font-medium ${ selectedTime === displayTime ? selectedTimeIsFull ? 'border-blue-500 bg-blue-50 text-blue-500' : 'border-orange-500 bg-orange-50 text-orange-500' : isFull - ? 'border-gray-100 bg-gray-50 text-gray-400' + ? 'border-dashed border-gray-300 text-gray-600 hover:border-blue-500 hover:text-blue-500' : 'border-gray-200 text-gray-700 hover:border-orange-500 hover:text-orange-500' }`} > + {isFull && } {displayTime} ); diff --git a/src/hooks/useDragScroll.ts b/src/hooks/useDragScroll.ts new file mode 100644 index 0000000..3bd853c --- /dev/null +++ b/src/hooks/useDragScroll.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; + +export function useDragScroll() { + const [node, setNode] = useState(null); + + const ref = useCallback((el: T | null) => { + setNode(el); + }, []); + + useEffect(() => { + const el = node; + if (!el) return; + + let isDown = false; + let startX = 0; + let scrollLeftStart = 0; + let hasMoved = false; + + const onMouseDown = (e: MouseEvent) => { + isDown = true; + hasMoved = false; + startX = e.pageX - el.offsetLeft; + scrollLeftStart = el.scrollLeft; + el.style.cursor = 'grabbing'; + }; + + const stopDrag = () => { + isDown = false; + el.style.cursor = 'grab'; + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDown) return; + const x = e.pageX - el.offsetLeft; + const walk = x - startX; + if (Math.abs(walk) > 5) hasMoved = true; + if (hasMoved) { + e.preventDefault(); + el.scrollLeft = scrollLeftStart - walk; + } + }; + + const onClickCapture = (e: MouseEvent) => { + if (hasMoved) { + e.preventDefault(); + e.stopPropagation(); + hasMoved = false; + } + }; + + el.style.cursor = 'grab'; + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('mouseleave', stopDrag); + el.addEventListener('mouseup', stopDrag); + el.addEventListener('mousemove', onMouseMove); + el.addEventListener('click', onClickCapture, true); + + return () => { + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('mouseleave', stopDrag); + el.removeEventListener('mouseup', stopDrag); + el.removeEventListener('mousemove', onMouseMove); + el.removeEventListener('click', onClickCapture, true); + }; + }, [node]); + + return ref; +} From 4042cc84ed82c1e27f49f50fa1a00e213b1a8722 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 18 May 2026 11:25:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20PR=20#54=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 진입 버튼 라벨 조건 단순화 (분기 4개 → 3개) - useDragScroll의 offsetLeft 계산 제거 (delta 산출에 불필요) --- src/app/stores/[id]/page.tsx | 12 +++++------- src/hooks/useDragScroll.ts | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/app/stores/[id]/page.tsx b/src/app/stores/[id]/page.tsx index 7a660c8..e9f851a 100644 --- a/src/app/stores/[id]/page.tsx +++ b/src/app/stores/[id]/page.tsx @@ -409,13 +409,11 @@ export default function StoreDetail() { isSelectedFullyBooked ? 'bg-blue-500 hover:bg-blue-600' : 'bg-orange-500 hover:bg-orange-600' }`} > - {slotState === 'unknown' - ? (isSelectedFullyBooked ? '시간대 선택하고 빈자리 알림 받기' : '예약하기') - : slotState === 'mixed' - ? '예약 또는 빈자리 알림' - : slotState === 'vacancy' - ? '시간대 선택하고 빈자리 알림 받기' - : '예약하기'} + {slotState === 'mixed' + ? '예약 또는 빈자리 알림' + : (slotState === 'vacancy' || (slotState === 'unknown' && isSelectedFullyBooked)) + ? '시간대 선택하고 빈자리 알림 받기' + : '예약하기'} )}
diff --git a/src/hooks/useDragScroll.ts b/src/hooks/useDragScroll.ts index 3bd853c..4a40d15 100644 --- a/src/hooks/useDragScroll.ts +++ b/src/hooks/useDragScroll.ts @@ -19,7 +19,7 @@ export function useDragScroll() { const onMouseDown = (e: MouseEvent) => { isDown = true; hasMoved = false; - startX = e.pageX - el.offsetLeft; + startX = e.pageX; scrollLeftStart = el.scrollLeft; el.style.cursor = 'grabbing'; }; @@ -31,7 +31,7 @@ export function useDragScroll() { const onMouseMove = (e: MouseEvent) => { if (!isDown) return; - const x = e.pageX - el.offsetLeft; + const x = e.pageX; const walk = x - startX; if (Math.abs(walk) > 5) hasMoved = true; if (hasMoved) { From d3d1069d71ab26da8cf72399581cc3e7153e9365 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 18 May 2026 14:41:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 페이지: 빈 문자열 쿼리 파라미터 처리를 위해 ?? → || 변경 - 카테고리 페이지: handleCategoryClick, handleDistrictClick을 useCallback으로 래핑 - 매장 상세 페이지: slotState가 unknown일 때 버튼 비활성화 처리 - 매장 상세 페이지: 시간 슬롯 버튼 스타일 분기를 getTimeButtonStyles 헬퍼 함수로 추출 --- src/app/category/page.tsx | 14 +++++++------- src/app/stores/[id]/page.tsx | 29 +++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/app/category/page.tsx b/src/app/category/page.tsx index 2d60c84..539925e 100644 --- a/src/app/category/page.tsx +++ b/src/app/category/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect, useRef, useState } from 'react'; +import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { ChevronDown, Check } from 'lucide-react'; import Header from '@/components/common/Header'; @@ -24,8 +24,8 @@ function CategoryContent() { isFetchingNextPage, } = useStoresInfiniteQuery( { - category: selectedCategory ?? undefined, - district: selectedDistrict ?? undefined, + category: selectedCategory || undefined, + district: selectedDistrict || undefined, }, 10, ); @@ -56,14 +56,14 @@ function CategoryContent() { return qs ? `/category?${qs}` : '/category'; }; - const handleCategoryClick = (categoryEnum: string | null) => { + const handleCategoryClick = useCallback((categoryEnum: string | null) => { router.push(buildHref(categoryEnum, selectedDistrict)); - }; + }, [router, selectedDistrict]); - const handleDistrictClick = (districtEnum: string | null) => { + const handleDistrictClick = useCallback((districtEnum: string | null) => { router.push(buildHref(selectedCategory, districtEnum)); setIsDistrictOpen(false); - }; + }, [router, selectedCategory]); const categoryScrollRef = useDragScroll(); diff --git a/src/app/stores/[id]/page.tsx b/src/app/stores/[id]/page.tsx index e9f851a..202d1e4 100644 --- a/src/app/stores/[id]/page.tsx +++ b/src/app/stores/[id]/page.tsx @@ -36,6 +36,18 @@ function getNextDays(count: number) { return days; } +function getTimeButtonStyles(isSelected: boolean, isSelectedFull: boolean, isFull: boolean) { + if (isSelected) { + return isSelectedFull + ? 'border-blue-500 bg-blue-50 text-blue-500' + : 'border-orange-500 bg-orange-50 text-orange-500'; + } + if (isFull) { + return 'border-dashed border-gray-300 text-gray-600 hover:border-blue-500 hover:text-blue-500'; + } + return 'border-gray-200 text-gray-700 hover:border-orange-500 hover:text-orange-500'; +} + export default function StoreDetail() { const router = useRouter(); const params = useParams<{ id: string }>(); @@ -402,6 +414,13 @@ export default function StoreDetail() { > 현재 휴업중입니다 + ) : slotState === 'unknown' ? ( + ) : ( @@ -483,13 +502,7 @@ export default function StoreDetail() { setSelectedTimeIsFull(isFull); } }} - className={`flex items-center justify-center gap-1 rounded-lg border py-2 text-sm font-medium ${ - selectedTime === displayTime - ? selectedTimeIsFull ? 'border-blue-500 bg-blue-50 text-blue-500' : 'border-orange-500 bg-orange-50 text-orange-500' - : isFull - ? 'border-dashed border-gray-300 text-gray-600 hover:border-blue-500 hover:text-blue-500' - : 'border-gray-200 text-gray-700 hover:border-orange-500 hover:text-orange-500' - }`} + className={`flex items-center justify-center gap-1 rounded-lg border py-2 text-sm font-medium ${getTimeButtonStyles(selectedTime === displayTime, selectedTimeIsFull, isFull)}`} > {isFull && } {displayTime}