Skip to content
Open
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
106 changes: 96 additions & 10 deletions src/app/category/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,7 +23,10 @@ function CategoryContent() {
hasNextPage,
isFetchingNextPage,
} = useStoresInfiniteQuery(
{ category: selectedCategory ?? undefined },
{
category: selectedCategory || undefined,
district: selectedDistrict || undefined,
},
10,
);

Expand All @@ -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<HTMLDivElement>();

const [isDistrictOpen, setIsDistrictOpen] = useState(false);
const districtDropdownRef = useRef<HTMLDivElement>(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 (
<>
<Header showSearch showBack />
<main className="flex-1">
{/* ์นดํ…Œ๊ณ ๋ฆฌ ํƒญ */}
<div className="flex gap-2 overflow-x-auto border-b border-gray-100 px-4 py-3">
<div
ref={categoryScrollRef}
className="flex gap-2 overflow-x-auto border-b border-gray-100 px-4 py-3 select-none"
>
<button
onClick={() => handleCategoryClick(null)}
className={`flex-shrink-0 rounded-full px-4 py-2 text-sm ${
Expand All @@ -81,6 +118,55 @@ function CategoryContent() {
))}
</div>

{/* ์ง€์—ญ ํ•„ํ„ฐ (๋“œ๋กญ๋‹ค์šด) */}
<div className="border-b border-gray-100 px-4 py-3">
<div ref={districtDropdownRef} className="relative inline-block">
<button
onClick={() => setIsDistrictOpen((v) => !v)}
className={`flex items-center gap-1 rounded-full px-4 py-2 text-sm ${
selectedDistrict
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors'
}`}
>
<span>{selectedDistrict ? toDistrictLabel(selectedDistrict) : '์ง€์—ญ ์„ ํƒ'}</span>
<ChevronDown
size={16}
className={`transition-transform ${isDistrictOpen ? 'rotate-180' : ''}`}
/>
</button>

{isDistrictOpen && (
<div className="absolute left-0 top-full z-20 mt-2 max-h-72 w-44 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg">
<button
onClick={() => handleDistrictClick(null)}
className={`flex w-full items-center justify-between px-4 py-2.5 text-left text-sm hover:bg-gray-50 ${
selectedDistrict === null ? 'font-semibold text-orange-500' : 'text-gray-700'
}`}
>
<span>์ „์ฒด ์ง€์—ญ</span>
{selectedDistrict === null && <Check size={16} className="text-orange-500" />}
</button>
{STORE_DISTRICTS.map((district) => {
const isActive = selectedDistrict === district.enumValue;
return (
<button
key={district.enumValue}
onClick={() => handleDistrictClick(district.enumValue)}
className={`flex w-full items-center justify-between px-4 py-2.5 text-left text-sm hover:bg-gray-50 ${
isActive ? 'font-semibold text-orange-500' : 'text-gray-700'
}`}
>
<span>{district.label}</span>
{isActive && <Check size={16} className="text-orange-500" />}
</button>
);
})}
</div>
)}
</div>
</div>

{/* ๋งค์žฅ ๋ชฉ๋ก */}
<div className="px-4">
{isLoading ? (
Expand Down
59 changes: 47 additions & 12 deletions src/app/stores/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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 }>();
Expand All @@ -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<HTMLDivElement>();
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const [selectedRemainId, setSelectedRemainId] = useState<number | null>(null);
Expand Down Expand Up @@ -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 (
<Fragment key={`loading-${id}`}>
Expand Down Expand Up @@ -235,7 +260,7 @@ export default function StoreDetail() {
ํœด์—…์ค‘์ธ ๋งค์žฅ์€ ์˜ˆ์•ฝ์ด ๋ถˆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
</p>
) : (
<div className="flex gap-2 overflow-x-auto">
<div ref={dateStripRef} className="flex gap-2 overflow-x-auto select-none">
{days.map((date) => {
const { month, day, weekday } = formatDateParts(date);
const isToday =
Expand Down Expand Up @@ -389,14 +414,25 @@ export default function StoreDetail() {
>
ํ˜„์žฌ ํœด์—…์ค‘์ž…๋‹ˆ๋‹ค
</button>
) : slotState === 'unknown' ? (
<button
disabled
className="w-full cursor-not-allowed rounded-lg bg-gray-300 py-3 text-sm font-semibold text-white"
>
๋กœ๋”ฉ์ค‘...
</button>
) : (
<button
onClick={handleReserveClick}
className={`w-full rounded-lg py-3 text-sm font-semibold text-white transition-colors ${
isSelectedFullyBooked ? 'bg-blue-500 hover:bg-blue-600' : 'bg-orange-500 hover:bg-orange-600'
}`}
>
{isSelectedFullyBooked ? '์‹œ๊ฐ„๋Œ€ ์„ ํƒํ•˜๊ณ  ๋นˆ์ž๋ฆฌ ์•Œ๋ฆผ ๋ฐ›๊ธฐ' : '์˜ˆ์•ฝํ•˜๊ธฐ'}
{slotState === 'mixed'
? '์˜ˆ์•ฝ ๋˜๋Š” ๋นˆ์ž๋ฆฌ ์•Œ๋ฆผ'
: slotState === 'vacancy'
? '์‹œ๊ฐ„๋Œ€ ์„ ํƒํ•˜๊ณ  ๋นˆ์ž๋ฆฌ ์•Œ๋ฆผ ๋ฐ›๊ธฐ'
: '์˜ˆ์•ฝํ•˜๊ธฐ'}
Comment on lines +431 to +435

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is a potential UX issue when slotState is unknown but isSelectedFullyBooked is true. The button text suggests the user can 'Select a time and get a vacancy notification', but if times is empty (which causes the unknown state), the BottomSheet will show 'No available times' and the user won't be able to select anything. Consider handling the case where no time slots are available at all for a fully booked day to avoid leading the user into a dead end.

</button>
)}
</div>
Expand Down Expand Up @@ -442,8 +478,12 @@ export default function StoreDetail() {
{/* ์‹œ๊ฐ„ ์„ ํƒ */}
<div className="mb-5 mt-2">
<p className="mb-2 px-1 text-base font-medium text-gray-700">
{selectedDate.getMonth() + 1}์›” {selectedDate.getDate()}์ผ ์˜ˆ์•ฝ
๊ฐ€๋Šฅ ์‹œ๊ฐ„
{selectedDate.getMonth() + 1}์›” {selectedDate.getDate()}์ผ{' '}
{slotState === 'mixed'
? '์˜ˆ์•ฝ ๋˜๋Š” ๋นˆ์ž๋ฆฌ ์•Œ๋ฆผ'
: slotState === 'vacancy'
? '๋นˆ์ž๋ฆฌ ์•Œ๋ฆผ ๋ฐ›์„ ์‹œ๊ฐ„'
: '์˜ˆ์•ฝ ๊ฐ€๋Šฅ ์‹œ๊ฐ„'}
</p>
<div className="grid grid-cols-5 gap-2">
{isTimesLoading ? (
Expand All @@ -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 && <Bell size={12} />}
{displayTime}
</button>
);
Expand Down
68 changes: 68 additions & 0 deletions src/hooks/useDragScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from 'react';

export function useDragScroll<T extends HTMLElement>() {
const [node, setNode] = useState<T | null>(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;
}