diff --git a/src/app/schedule/(components)/current.tsx b/src/app/schedule/(components)/current.tsx new file mode 100644 index 00000000..08ac7bc1 --- /dev/null +++ b/src/app/schedule/(components)/current.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import Card from '@/components/shared/card'; + +const MOCK_MEETINGS = [ + { + id: 1, + title: '네즈코와 무한성에서 정모 하실 분', + images: [], + location: '네즈코 호수공원', + dateTime: '25. 11. 28 - 10:00', + nickName: 'Hope Lee', + participantCount: 8, + maxParticipants: 10, + tags: ['#태그', '#태그', '#태그'], + }, +]; + +export default function Current() { + const router = useRouter(); + + return ( +
+ {MOCK_MEETINGS.map((meeting) => ( + console.log('모임 탈퇴', meeting.id), + onChat: () => router.push(`/chat/${meeting.id}`), + }} + location={meeting.location} + maxParticipants={meeting.maxParticipants} + nickName={meeting.nickName} + participantCount={meeting.participantCount} + tags={meeting.tags} + title={meeting.title} + onClick={() => router.push(`/meetup/${meeting.id}`)} + /> + ))} +
+ ); +} diff --git a/src/app/schedule/(components)/history.tsx b/src/app/schedule/(components)/history.tsx new file mode 100644 index 00000000..ff6b0ec2 --- /dev/null +++ b/src/app/schedule/(components)/history.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import Card from '@/components/shared/card'; + +const MOCK_MEETINGS = [ + { + id: 3, + title: '동네 책모임 신규 멤버 구함', + images: [], + location: '망원동 카페 거리', + dateTime: '25. 12. 10 - 19:30', + nickName: 'Book Lover', + participantCount: 3, + maxParticipants: 8, + tags: ['#책모임', '#수다환영'], + }, +]; + +export default function History() { + const router = useRouter(); + + return ( +
+ {MOCK_MEETINGS.map((meeting) => ( + console.log('모임 탈퇴', meeting.id), + onChat: () => router.push(`/chat/${meeting.id}`), + }} + location={meeting.location} + maxParticipants={meeting.maxParticipants} + nickName={meeting.nickName} + participantCount={meeting.participantCount} + tags={meeting.tags} + title={meeting.title} + onClick={() => router.push(`/meetup/${meeting.id}`)} + /> + ))} +
+ ); +} diff --git a/src/app/schedule/(components)/my.tsx b/src/app/schedule/(components)/my.tsx new file mode 100644 index 00000000..c650feda --- /dev/null +++ b/src/app/schedule/(components)/my.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import Card from '@/components/shared/card'; + +const MOCK_MEETINGS = [ + { + id: 2, + title: '주말 러닝 크루 모집', + images: [], + location: '한강공원 잠실지구', + dateTime: '25. 12. 05 - 07:30', + nickName: 'Minseo Kim', + participantCount: 5, + maxParticipants: 12, + tags: ['#러닝', '#아침운동'], + }, +]; + +export default function My() { + const router = useRouter(); + + return ( +
+ {MOCK_MEETINGS.map((meeting) => ( + console.log('모임 탈퇴', meeting.id), + onChat: () => router.push(`/chat/${meeting.id}`), + }} + location={meeting.location} + maxParticipants={meeting.maxParticipants} + nickName={meeting.nickName} + participantCount={meeting.participantCount} + tags={meeting.tags} + title={meeting.title} + onClick={() => router.push(`/meetup/${meeting.id}`)} + /> + ))} +
+ ); +} diff --git a/src/app/schedule/current/page.tsx b/src/app/schedule/current/page.tsx deleted file mode 100644 index 5b43b5a0..00000000 --- a/src/app/schedule/current/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TabNavigation } from '@/components/shared'; - -const SCHEDULE_TABS = [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, -]; - -export default function ScheduleCurrentPage() { - return ( -
- -
-

현재 모임 페이지

-
-
- ); -} diff --git a/src/app/schedule/history/page.tsx b/src/app/schedule/history/page.tsx deleted file mode 100644 index 69dbabcb..00000000 --- a/src/app/schedule/history/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TabNavigation } from '@/components/shared'; - -const SCHEDULE_TABS = [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, -]; - -export default function ScheduleHistoryPage() { - return ( -
- -
-

모임 이력 페이지

-
-
- ); -} diff --git a/src/app/schedule/my/page.tsx b/src/app/schedule/my/page.tsx deleted file mode 100644 index 8a63b6e0..00000000 --- a/src/app/schedule/my/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TabNavigation } from '@/components/shared'; - -const SCHEDULE_TABS = [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, -]; - -export default function ScheduleMyPage() { - return ( -
- -
-

나의 모임 페이지

-
-
- ); -} diff --git a/src/app/schedule/page.tsx b/src/app/schedule/page.tsx index 72608500..c41fe9b1 100644 --- a/src/app/schedule/page.tsx +++ b/src/app/schedule/page.tsx @@ -1,5 +1,30 @@ -import { redirect } from 'next/navigation'; +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { TabNavigation } from '@/components/shared'; + +import Current from './(components)/current'; +import History from './(components)/history'; +import My from './(components)/my'; + +const SCHEDULE_TABS = [ + { label: '현재 모임', value: 'current' }, + { label: '나의 모임', value: 'my' }, + { label: '모임 이력', value: 'history' }, +]; export default function SchedulePage() { - redirect('/schedule/current'); + const searchParams = useSearchParams(); + const tab = searchParams.get('tab') || 'current'; + + return ( +
+ + + {tab === 'current' && } + {tab === 'my' && } + {tab === 'history' && } +
+ ); } diff --git a/src/components/shared/card/card-participation-row/index.tsx b/src/components/shared/card/card-participation-row/index.tsx index df23aac3..bd34bd61 100644 --- a/src/components/shared/card/card-participation-row/index.tsx +++ b/src/components/shared/card/card-participation-row/index.tsx @@ -12,7 +12,7 @@ export const CardParticipationRow = ({ progress, }: CardParticipationRowProps) => { return ( -
+
diff --git a/src/components/shared/card/index.stories.tsx b/src/components/shared/card/index.stories.tsx index ff7df1b4..acf78bd5 100644 --- a/src/components/shared/card/index.stories.tsx +++ b/src/components/shared/card/index.stories.tsx @@ -49,3 +49,13 @@ export const ManyTags: Story = { tags: ['#러닝', '#아침운동', '#주말', '#건강', '#친목', '#초보환영'], }, }; + +export const WithActions: Story = { + args: { + ...Default.args, + leaveAndChatActions: { + onLeave: () => alert('모임 탈퇴'), + onChat: () => alert('채팅 입장'), + }, + }, +}; diff --git a/src/components/shared/card/index.test.tsx b/src/components/shared/card/index.test.tsx index 6481e3dd..d402e7f7 100644 --- a/src/components/shared/card/index.test.tsx +++ b/src/components/shared/card/index.test.tsx @@ -39,10 +39,12 @@ describe('Card', () => { const user = userEvent.setup(); const handleClick = jest.fn(); - render(); + const { container } = render(); - const button = screen.getByRole('button'); - await user.click(button); + const card = container.querySelector('.cursor-pointer'); + expect(card).toBeInTheDocument(); + + await user.click(card!); expect(handleClick).toHaveBeenCalledTimes(1); }); diff --git a/src/components/shared/card/index.tsx b/src/components/shared/card/index.tsx index 1d75dc57..267d7cef 100644 --- a/src/components/shared/card/index.tsx +++ b/src/components/shared/card/index.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import { useState } from 'react'; +import { Button } from '@/components/ui'; import { cn } from '@/lib/utils'; import { CardInfoRow } from './card-info-row'; @@ -22,6 +23,22 @@ type CardProps = { maxParticipants: number; profileImage?: string | null; onClick?: () => void; + leaveAndChatActions?: { + onLeave: () => void; + onChat: () => void; + }; +}; + +const PROFILE_IMAGE_SIZE = 16; + +const calculateProgress = (count: number, max: number): number => { + const safeMax = max > 0 ? max : 1; + const rawProgress = (count / safeMax) * 100; + return Math.min(100, Math.max(0, rawProgress)); +}; + +const convertToCardTags = (tags: string[]): CardTag[] => { + return tags.map((tag, index) => ({ id: index, label: tag })); }; const Card = ({ @@ -35,21 +52,21 @@ const Card = ({ maxParticipants, profileImage, onClick, + leaveAndChatActions, }: CardProps) => { - const safeMaxCount = maxParticipants > 0 ? maxParticipants : 1; - const rawProgress = (participantCount / safeMaxCount) * 100; - const progress = Math.min(100, Math.max(0, rawProgress)); const [imageError, setImageError] = useState(false); - const Wrapper: 'button' | 'div' = onClick ? 'button' : 'div'; const thumbnail = images?.[0]; const hasThumbnail = !!thumbnail && !imageError; - const cardTags: CardTag[] = tags.map((tag, index) => ({ id: index, label: tag })); + const cardTags = convertToCardTags(tags); + const progress = calculateProgress(participantCount, maxParticipants); return ( -
@@ -64,17 +81,34 @@ const Card = ({
{profileImage ? ( {nickName} ) : ( -
+
)} {nickName}
+ + {leaveAndChatActions && ( + + )}
@@ -82,7 +116,7 @@ const Card = ({ -
+
@@ -92,9 +126,23 @@ const Card = ({ participantCount={participantCount} progress={progress} /> + + {leaveAndChatActions && ( + + )}
- +
); }; diff --git a/src/components/shared/tab-navigation/index.stories.tsx b/src/components/shared/tab-navigation/index.stories.tsx index 4101035e..0da7b11d 100644 --- a/src/components/shared/tab-navigation/index.stories.tsx +++ b/src/components/shared/tab-navigation/index.stories.tsx @@ -11,9 +11,17 @@ const meta = { tags: ['autodocs'], argTypes: { tabs: { - description: '표시할 탭 목록 (2개 or 3개)', + description: '표시할 탭 목록 (2-3개)', control: 'object', }, + basePath: { + description: '기본 경로', + control: 'text', + }, + paramName: { + description: '쿼리 파라미터 이름 (기본: tab)', + control: 'text', + }, }, } satisfies Meta; @@ -25,16 +33,17 @@ type Story = StoryObj; */ export const TwoTabsFirst: Story = { args: { + basePath: '/following', tabs: [ - { label: '팔로잉', path: '/following' }, - { label: '메세지', path: '/message' }, + { label: '팔로잉', value: 'list' }, + { label: '메세지', value: 'message' }, ], }, parameters: { nextjs: { appDirectory: true, navigation: { - pathname: '/following', + query: { tab: 'list' }, }, }, }, @@ -45,16 +54,17 @@ export const TwoTabsFirst: Story = { */ export const TwoTabsSecond: Story = { args: { + basePath: '/following', tabs: [ - { label: '팔로잉', path: '/following' }, - { label: '메세지', path: '/message' }, + { label: '팔로잉', value: 'list' }, + { label: '메세지', value: 'message' }, ], }, parameters: { nextjs: { appDirectory: true, navigation: { - pathname: '/message', + query: { tab: 'message' }, }, }, }, @@ -65,17 +75,18 @@ export const TwoTabsSecond: Story = { */ export const ThreeTabsFirst: Story = { args: { + basePath: '/schedule', tabs: [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, + { label: '현재 모임', value: 'current' }, + { label: '나의 모임', value: 'my' }, + { label: '모임 이력', value: 'history' }, ], }, parameters: { nextjs: { appDirectory: true, navigation: { - pathname: '/schedule/current', + query: { tab: 'current' }, }, }, }, @@ -86,17 +97,18 @@ export const ThreeTabsFirst: Story = { */ export const ThreeTabsSecond: Story = { args: { + basePath: '/schedule', tabs: [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, + { label: '현재 모임', value: 'current' }, + { label: '나의 모임', value: 'my' }, + { label: '모임 이력', value: 'history' }, ], }, parameters: { nextjs: { appDirectory: true, navigation: { - pathname: '/schedule/my', + query: { tab: 'my' }, }, }, }, @@ -107,17 +119,18 @@ export const ThreeTabsSecond: Story = { */ export const ThreeTabsThird: Story = { args: { + basePath: '/schedule', tabs: [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, + { label: '현재 모임', value: 'current' }, + { label: '나의 모임', value: 'my' }, + { label: '모임 이력', value: 'history' }, ], }, parameters: { nextjs: { appDirectory: true, navigation: { - pathname: '/schedule/history', + query: { tab: 'history' }, }, }, }, diff --git a/src/components/shared/tab-navigation/index.test.tsx b/src/components/shared/tab-navigation/index.test.tsx index e7776091..fd50074d 100644 --- a/src/components/shared/tab-navigation/index.test.tsx +++ b/src/components/shared/tab-navigation/index.test.tsx @@ -1,125 +1,130 @@ -import { usePathname } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import { render, screen } from '@testing-library/react'; import { TabNavigation } from './index'; jest.mock('next/navigation', () => ({ - usePathname: jest.fn(), + useSearchParams: jest.fn(), })); describe('TabNavigation', () => { - const mockUsePathname = usePathname as jest.Mock; + const mockUseSearchParams = useSearchParams as jest.Mock; const twoTabs = [ - { label: '팔로잉', path: '/following' }, - { label: '메세지', path: '/message' }, + { label: '팔로잉', value: 'list' }, + { label: '메세지', value: 'message' }, ]; const threeTabs = [ - { label: '현재 모임', path: '/schedule/current' }, - { label: '나의 모임', path: '/schedule/my' }, - { label: '모임 이력', path: '/schedule/history' }, + { label: '현재 모임', value: 'current' }, + { label: '나의 모임', value: 'my' }, + { label: '모임 이력', value: 'history' }, ]; + const createMockSearchParams = (tab: string | null) => ({ + get: jest.fn((key: string) => (key === 'tab' ? tab : null)), + }); + beforeEach(() => { jest.clearAllMocks(); }); describe('렌더링', () => { test('2개의 탭을 렌더링한다', () => { - mockUsePathname.mockReturnValue('/following'); - render(); + mockUseSearchParams.mockReturnValue(createMockSearchParams('list')); + render(); expect(screen.getByText('팔로잉')).toBeInTheDocument(); expect(screen.getByText('메세지')).toBeInTheDocument(); }); + test('sticky 스타일이 적용된다', () => { + mockUseSearchParams.mockReturnValue(createMockSearchParams('list')); + const { container } = render(); + + const nav = container.querySelector('nav'); + expect(nav).toHaveClass('sticky'); + expect(nav).toHaveClass('top-0'); + expect(nav).toHaveClass('z-50'); + }); + test('3개의 탭을 렌더링한다', () => { - mockUsePathname.mockReturnValue('/schedule/current'); - render(); + mockUseSearchParams.mockReturnValue(createMockSearchParams('current')); + render(); expect(screen.getByText('현재 모임')).toBeInTheDocument(); expect(screen.getByText('나의 모임')).toBeInTheDocument(); expect(screen.getByText('모임 이력')).toBeInTheDocument(); }); - test('각 탭이 올바른 경로로 링크된다', () => { - mockUsePathname.mockReturnValue('/following'); - render(); + test('각 탭이 올바른 쿼리 파라미터로 링크된다', () => { + mockUseSearchParams.mockReturnValue(createMockSearchParams('list')); + render(); const followingLink = screen.getByRole('link', { name: '팔로잉' }); const messageLink = screen.getByRole('link', { name: '메세지' }); - expect(followingLink).toHaveAttribute('href', '/following'); - expect(messageLink).toHaveAttribute('href', '/message'); + expect(followingLink).toHaveAttribute('href', '/following?tab=list'); + expect(messageLink).toHaveAttribute('href', '/following?tab=message'); }); }); describe('활성 탭 스타일', () => { - test('현재 경로와 일치하는 탭에 mint-600 색상을 적용한다', () => { - mockUsePathname.mockReturnValue('/following'); - render(); + test('현재 탭과 일치하는 탭에 mint-600 색상을 적용한다', () => { + mockUseSearchParams.mockReturnValue(createMockSearchParams('list')); + render(); const followingLink = screen.getByRole('link', { name: '팔로잉' }); expect(followingLink).toHaveClass('text-mint-600'); }); - test('현재 경로와 일치하지 않는 탭에 gray-600 색상을 적용한다', () => { - mockUsePathname.mockReturnValue('/following'); - render(); + test('현재 탭과 일치하지 않는 탭에 gray-600 색상을 적용한다', () => { + mockUseSearchParams.mockReturnValue(createMockSearchParams('list')); + render(); const messageLink = screen.getByRole('link', { name: '메세지' }); expect(messageLink).toHaveClass('text-gray-600'); }); test('활성 탭에만 mint-500 인디케이터를 표시한다', () => { - mockUsePathname.mockReturnValue('/schedule/my'); - const { container } = render(); + mockUseSearchParams.mockReturnValue(createMockSearchParams('my')); + const { container } = render(); const indicators = container.querySelectorAll('.bg-mint-500'); expect(indicators).toHaveLength(1); }); }); - describe('경로 변경', () => { - test('경로가 변경되면 활성 탭이 업데이트된다', () => { - mockUsePathname.mockReturnValue('/schedule/current'); - const { rerender } = render(); + describe('쿼리 파라미터', () => { + test('파라미터가 없으면 첫 번째 탭이 활성화된다', () => { + mockUseSearchParams.mockReturnValue(createMockSearchParams(null)); + render(); - let currentLink = screen.getByRole('link', { name: '현재 모임' }); + const currentLink = screen.getByRole('link', { name: '현재 모임' }); expect(currentLink).toHaveClass('text-mint-600'); + }); - // 경로 변경 - mockUsePathname.mockReturnValue('/schedule/my'); - rerender(); + test('커스텀 paramName을 사용할 수 있다', () => { + const customMockSearchParams = { + get: jest.fn((key: string) => (key === 'section' ? 'list' : null)), + }; + mockUseSearchParams.mockReturnValue(customMockSearchParams); - const myLink = screen.getByRole('link', { name: '나의 모임' }); - expect(myLink).toHaveClass('text-mint-600'); + render(); - currentLink = screen.getByRole('link', { name: '현재 모임' }); - expect(currentLink).toHaveClass('text-gray-600'); + const followingLink = screen.getByRole('link', { name: '팔로잉' }); + expect(followingLink).toHaveAttribute('href', '/following?section=list'); }); }); describe('엣지 케이스', () => { - test('일치하는 경로가 없으면 모든 탭이 비활성 상태다', () => { - mockUsePathname.mockReturnValue('/unknown-path'); - render(); + test('유효하지 않은 탭 값이면 첫 번째 탭이 활성화된다', () => { + mockUseSearchParams.mockReturnValue(createMockSearchParams('invalid')); + const { container } = render(); - const followingLink = screen.getByRole('link', { name: '팔로잉' }); - const messageLink = screen.getByRole('link', { name: '메세지' }); - - expect(followingLink).toHaveClass('text-gray-600'); - expect(messageLink).toHaveClass('text-gray-600'); - }); - - test('빈 탭 배열을 처리한다', () => { - mockUsePathname.mockReturnValue('/'); - const { container } = render(); - - const tabs = container.querySelectorAll('li'); - expect(tabs).toHaveLength(0); + const indicators = container.querySelectorAll('.bg-mint-500'); + expect(indicators).toHaveLength(0); }); }); }); diff --git a/src/components/shared/tab-navigation/index.tsx b/src/components/shared/tab-navigation/index.tsx index af834bbc..070aa0e5 100644 --- a/src/components/shared/tab-navigation/index.tsx +++ b/src/components/shared/tab-navigation/index.tsx @@ -1,20 +1,24 @@ 'use client'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { useSearchParams } from 'next/navigation'; import { cn } from '@/lib/utils'; export interface Tab { label: string; - path: string; + value: string; } interface TabNavigationProps { tabs: Tab[]; + paramName?: string; + basePath?: string; } -interface TabItemProps extends Tab { +interface TabItemProps { + label: string; + href: string; isActive: boolean; } @@ -27,10 +31,10 @@ const TAB_TEXT_STYLES = { const INDICATOR_STYLES = 'absolute bottom-0 left-0 z-10 h-[2px] w-full bg-mint-500'; const BASE_LINE_STYLES = 'absolute bottom-0 left-0 right-0 h-[2px] bg-gray-200'; -const TabItem = ({ label, path, isActive }: TabItemProps) => ( +const TabItem = ({ label, href, isActive }: TabItemProps) => (
  • (
  • ); -export const TabNavigation = ({ tabs }: TabNavigationProps) => { - const pathname = usePathname(); +export const TabNavigation = ({ tabs, paramName = 'tab', basePath = '' }: TabNavigationProps) => { + const searchParams = useSearchParams(); + const currentTab = searchParams.get(paramName) || tabs[0].value; return ( -