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}
+
+ {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 (
-