diff --git a/src/app/category/page.tsx b/src/app/category/page.tsx index 2655824..539925e 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, useCallback, 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 handleCategoryClick = (categoryEnum: string | null) => { - if (categoryEnum) { - router.push(`/category?selected=${categoryEnum}`); - } else { - router.push('/category'); - } + 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 = useCallback((categoryEnum: string | null) => { + router.push(buildHref(categoryEnum, selectedDistrict)); + }, [router, selectedDistrict]); + + const handleDistrictClick = useCallback((districtEnum: string | null) => { + router.push(buildHref(selectedCategory, districtEnum)); + setIsDistrictOpen(false); + }, [router, selectedCategory]); + + 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..202d1e4 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, @@ -35,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 }>(); @@ -49,6 +62,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 +115,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 +260,7 @@ export default function StoreDetail() { 휴업중인 매장은 예약이 불가합니다.

) : ( -
+
{days.map((date) => { const { month, day, weekday } = formatDateParts(date); const isToday = @@ -389,6 +414,13 @@ export default function StoreDetail() { > 현재 휴업중입니다 + ) : slotState === 'unknown' ? ( + ) : ( )}
@@ -442,8 +478,12 @@ export default function StoreDetail() { {/* 시간 선택 */}

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

{isTimesLoading ? ( @@ -462,14 +502,9 @@ export default function StoreDetail() { setSelectedTimeIsFull(isFull); } }} - className={`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-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} ); diff --git a/src/hooks/useDragScroll.ts b/src/hooks/useDragScroll.ts new file mode 100644 index 0000000..4a40d15 --- /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; + 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; + 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; +}