diff --git a/.gitignore b/.gitignore index 68459fbb..650c5506 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* # local env files .env*.local +.env # vercel .vercel @@ -35,7 +36,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -*storybook.log # storybook -/storybook-static \ No newline at end of file +/storybook-static +*storybook.log \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 8cb14913..652ed24d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -30,13 +30,5 @@ const nextConfig = { }, ]; }, - async rewrites() { - return [ - { - source: '/:path*', - destination: `${process.env.NEXT_PUBLIC_API_BASE_URL}/:path*`, - }, - ]; - }, }; export default nextConfig; diff --git a/src/_apis/detail/get-crew-detail.ts b/src/_apis/detail/get-crew-detail.ts index 7764d00c..e2a7f13c 100644 --- a/src/_apis/detail/get-crew-detail.ts +++ b/src/_apis/detail/get-crew-detail.ts @@ -1,13 +1,14 @@ import { fetchApi } from '@/src/utils/api'; import { CrewDetail } from '@/src/types/crew-card'; -export async function getCrewDetail(): Promise<{ data: CrewDetail }> { - const response = await fetchApi('/crewDetail', { +export async function getCrewDetail(id: number): Promise { + const url = `/api/crews/${id}`; + + const response = await fetchApi<{ data: CrewDetail }>(url, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); - - return { data: response[0] }; + return response.data; } diff --git a/src/_apis/detail/get-gathering-detail.ts b/src/_apis/detail/get-gathering-detail.ts new file mode 100644 index 00000000..8ca0dc31 --- /dev/null +++ b/src/_apis/detail/get-gathering-detail.ts @@ -0,0 +1,17 @@ +import { fetchApi } from '@/src/utils/api'; +import { GatheringDetailType } from '@/src/types/gathering-data'; + +export async function GetGatheringDetail( + crewId: number, + gatheringId: number, +): Promise { + const url = `/api/crews/${crewId}/gatherings/${gatheringId}`; + + const response = await fetchApi<{ data: GatheringDetailType }>(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return response.data; +} diff --git a/src/_apis/detail/get-gathering-list.ts b/src/_apis/detail/get-gathering-list.ts index 53e1ee3d..68d966a7 100644 --- a/src/_apis/detail/get-gathering-list.ts +++ b/src/_apis/detail/get-gathering-list.ts @@ -1,11 +1,14 @@ import { fetchApi } from '@/src/utils/api'; import { GatheringType } from '@/src/types/gathering-data'; -export async function getGatheringList(): Promise { - return fetchApi('/gatherings', { +export async function getGatheringList(id: number): Promise { + const url = `/api/crews/${id}/gatherings`; + + const response = await fetchApi<{ data: GatheringType[] }>(url, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); + return response.data; } diff --git a/src/_queries/detail/crew-detail-queries.ts b/src/_queries/detail/crew-detail-queries.ts new file mode 100644 index 00000000..65d44914 --- /dev/null +++ b/src/_queries/detail/crew-detail-queries.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCrewDetail } from '@/src/_apis/detail/get-crew-detail'; + +export function useGetCrewDetailQuery(id: number) { + return useQuery({ + queryKey: ['crewDetail', id], + queryFn: () => getCrewDetail(id), + }); +} diff --git a/src/_queries/detail/gathering-detail-queries.ts b/src/_queries/detail/gathering-detail-queries.ts new file mode 100644 index 00000000..ad5fe8da --- /dev/null +++ b/src/_queries/detail/gathering-detail-queries.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { GetGatheringDetail } from '@/src/_apis/detail/get-gathering-detail'; + +export function useGetGatheringDetailQuery(crewId: number, gatheringId: number) { + return useQuery({ + queryKey: ['gatheringDetail', crewId, gatheringId], + queryFn: () => GetGatheringDetail(crewId, gatheringId), + }); +} diff --git a/src/_queries/detail/gathering-list-queries.ts b/src/_queries/detail/gathering-list-queries.ts new file mode 100644 index 00000000..dda91c30 --- /dev/null +++ b/src/_queries/detail/gathering-list-queries.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getGatheringList } from '@/src/_apis/detail/get-gathering-list'; +import { GatheringType } from '@/src/types/gathering-data'; + +export function useGetGatheringListQuery(id: number) { + return useQuery({ + queryKey: ['gatheringList', id], + queryFn: () => getGatheringList(id), + enabled: !!id, + }); +} diff --git a/src/app/(crew)/crew/detail/[id]/_components/detail-crew-section.tsx b/src/app/(crew)/crew/detail/[id]/_components/detail-crew-section.tsx new file mode 100644 index 00000000..e8fc67e6 --- /dev/null +++ b/src/app/(crew)/crew/detail/[id]/_components/detail-crew-section.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useGetCrewDetailQuery } from '@/src/_queries/detail/crew-detail-queries'; +import { ApiError } from '@/src/utils/api'; +import DetailCrewCard from '@/src/components/common/crew-list/detail-crew-card'; + +interface DetailCrewSectionProps { + id: number; +} + +export default function DetailCrewSection({ id }: DetailCrewSectionProps) { + const { data, isLoading, error } = useGetCrewDetailQuery(id); + + // TODO: 추후 에러 처리 수정 + if (isLoading) return

Loading...

; + + if (error) { + if (error instanceof ApiError) { + try { + const errorData = JSON.parse(error.message); + + if (errorData.status === 'NOT_FOUND') { + return

크루 정보를 찾을 수 없습니다

; + } + } catch { + return

{`Error ${error.status}: ${error.message}`}

; + } + } + return

데이터 통신에 실패했습니다.

; + } + + // data가 있을 때만 DetailCrewCard를 렌더링 + return data ? : null; +} diff --git a/src/app/(crew)/crew/detail/[id]/_components/gathering-list-section.tsx b/src/app/(crew)/crew/detail/[id]/_components/gathering-list-section.tsx new file mode 100644 index 00000000..63bd41f9 --- /dev/null +++ b/src/app/(crew)/crew/detail/[id]/_components/gathering-list-section.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useGetGatheringListQuery } from '@/src/_queries/detail/gathering-list-queries'; +import GatheringCardCarousel from '@/src/components/gathering-list/gathering-card-carousel'; + +interface GatheringListSectionProps { + id: number; +} + +export default function GatheringListSection({ id }: GatheringListSectionProps) { + const { data: gatheringList, isLoading, error } = useGetGatheringListQuery(id); + + if (isLoading) return

로딩 중...

; + + if (error) return

데이터를 불러오는 데 실패했습니다: {error.message}

; + + if (!gatheringList || gatheringList.length === 0) return

데이터가 없습니다.

; + + return ; +} diff --git a/src/app/(crew)/crew/detail/[id]/page.tsx b/src/app/(crew)/crew/detail/[id]/page.tsx index d5c439f9..3f0f863f 100644 --- a/src/app/(crew)/crew/detail/[id]/page.tsx +++ b/src/app/(crew)/crew/detail/[id]/page.tsx @@ -1,40 +1,42 @@ -import { getCrewDetail } from '@/src/_apis/detail/get-crew-detail'; import { getGatheringList } from '@/src/_apis/detail/get-gathering-list'; -import DetailCrewCard from '@/src/components/common/crew-list/detail-crew-card'; -import GatheringCardCarousel from '@/src/components/gathering-list/gathering-card-carousel'; import CreateGathering from './_components/create-gathering'; +import DetailCrewSection from './_components/detail-crew-section'; +import GatheringListSection from './_components/gathering-list-section'; import CrewReviewSection from './_components/review-section'; -export default async function CrewDetailPage() { - const { data: crewDetail } = await getCrewDetail(); - const gatheringList = await getGatheringList(); +interface CrewDetailPageProps { + params: { id: string }; +} + +export default async function CrewDetailPage({ params }: CrewDetailPageProps) { + const id = Number(params.id); return (
- {/* //TODO: 추후 confirmed부분 수정 */} - +

크루 약속

- + {/* */}
- +
-
+ {/* // TODO: 리뷰 완성되면 수정 */} + {/*

크루 리뷰

-
+
*/}
); diff --git a/src/components/common/crew-list/detail-crew-card.stories.tsx b/src/components/common/crew-list/detail-crew-card.stories.tsx index 66fa3378..708ceea5 100644 --- a/src/components/common/crew-list/detail-crew-card.stories.tsx +++ b/src/components/common/crew-list/detail-crew-card.stories.tsx @@ -1,10 +1,9 @@ -// CrewCard 스토리북 파일 import type { Meta, StoryObj } from '@storybook/react'; -import CrewCard from './detail-crew-card'; +import DetailCrewCard from './detail-crew-card'; const meta: Meta = { - title: 'Components/CrewCardList/CrewCard', - component: CrewCard, + title: 'Components/CrewCardList/DetailCrewCard', + component: DetailCrewCard, parameters: { layout: 'centered', nextjs: { @@ -13,50 +12,67 @@ const meta: Meta = { }, tags: ['autodocs'], args: { - id: 0, - title: '같이 물장구칠사람', - mainLocation: '대전광역시', - subLocation: '유성구', - imageUrl: 'https://i.pinimg.com/564x/f8/8d/c5/f88dc5b857caf6c303ae5ef9dd12e7fb.jpg', - totalGatheringCount: 5, // 기본 값 추가 - crewMembers: [ - { - id: 1, - nickname: 'John', - profileImageUrl: 'https://i.pinimg.com/564x/e2/25/bb/e225bb492dc7a20a549f3c0abec28eb8.jpg', - }, - { - id: 2, - nickname: 'Jane', - profileImageUrl: 'https://i.pinimg.com/564x/e2/25/bb/e225bb492dc7a20a549f3c0abec28eb8.jpg', - }, - ], // 기본 프로필 리스트 추가 + data: { + id: 1, + title: '같이 물장구칠사람', + mainLocation: '대전광역시', + subLocation: '유성구', + participantCount: 10, + totalCount: 20, + confirmed: true, + imageUrl: 'https://i.pinimg.com/564x/f8/8d/c5/f88dc5b857caf6c303ae5ef9dd12e7fb.jpg', + totalGatheringCount: 5, + crewMembers: [ + { + id: 1, + nickname: 'John', + profileImageUrl: + 'https://i.pinimg.com/564x/e2/25/bb/e225bb492dc7a20a549f3c0abec28eb8.jpg', + }, + { + id: 2, + nickname: 'Jane', + profileImageUrl: + 'https://i.pinimg.com/564x/e2/25/bb/e225bb492dc7a20a549f3c0abec28eb8.jpg', + }, + ], + }, }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { args: { - totalCount: 20, - participantCount: 10, - isConfirmed: true, + data: { + id: 1, + title: '같이 물장구칠사람', + mainLocation: '대전광역시', + subLocation: '유성구', + participantCount: 10, + totalCount: 20, + confirmed: true, + imageUrl: 'https://i.pinimg.com/564x/f8/8d/c5/f88dc5b857caf6c303ae5ef9dd12e7fb.jpg', + totalGatheringCount: 5, + crewMembers: [], // 빈 배열이라도 기본값으로 설정 + }, }, }; export const NotConfirmed: Story = { args: { - totalCount: 10, - participantCount: 1, - isConfirmed: false, - }, -}; - -export const Fulled: Story = { - args: { - totalCount: 5, - participantCount: 5, - isConfirmed: true, + data: { + id: 2, + title: '물장구 동호회', + mainLocation: '서울특별시', + subLocation: '강남구', + participantCount: 5, + totalCount: 15, + confirmed: false, + imageUrl: 'https://i.pinimg.com/564x/e2/25/bb/e225bb492dc7a20a549f3c0abec28eb8.jpg', + totalGatheringCount: 3, + crewMembers: [], + }, }, }; diff --git a/src/components/common/crew-list/detail-crew-card.tsx b/src/components/common/crew-list/detail-crew-card.tsx index 9c86190f..817ffbc1 100644 --- a/src/components/common/crew-list/detail-crew-card.tsx +++ b/src/components/common/crew-list/detail-crew-card.tsx @@ -11,30 +11,33 @@ import Check from '@/public/assets/icons/ic-check.svg'; import KebabIcon from '@/public/assets/icons/kebab-btn.svg'; interface DetailCrewCardProps { - id: number; - title: string; - mainLocation: string; - subLocation: string; - participantCount: number; - totalCount: number; - isConfirmed: boolean; - imageUrl: string; - totalGatheringCount: number; - crewMembers: CrewMember[]; + data: { + id: number; + title: string; + mainLocation: string; + subLocation: string; + participantCount: number; + totalCount: number; + confirmed: boolean; + imageUrl: string; + totalGatheringCount: number; + crewMembers: CrewMember[]; + }; } -export default function DetailCrewCard({ - id, - title, - mainLocation, - subLocation, - participantCount, - totalCount, - isConfirmed, - imageUrl, - totalGatheringCount, - crewMembers, -}: DetailCrewCardProps) { +export default function DetailCrewCard({ data }: DetailCrewCardProps) { + const { + id, + title, + mainLocation, + subLocation, + participantCount, + totalCount, + confirmed, + imageUrl, + totalGatheringCount, + crewMembers, + } = data; const [confirmCancelOpened, { open: openConfirmCancel, close: closeConfirmCancel }] = useDisclosure(); const [leaveCrewModalOpened, { open: openLeaveCrewModal, close: closeLeaveCrewModal }] = @@ -131,7 +134,7 @@ export default function DetailCrewCard({ - {isConfirmed && ( + {confirmed && ( 확인 개설 확정 diff --git a/src/components/common/gathering-card/container.tsx b/src/components/common/gathering-card/container.tsx index ae4bc525..962dae82 100644 --- a/src/components/common/gathering-card/container.tsx +++ b/src/components/common/gathering-card/container.tsx @@ -2,13 +2,16 @@ import { useEffect, useState } from 'react'; import { useDisclosure } from '@mantine/hooks'; -import { getGathering } from '@/src/_apis/gathering/gathering-apis'; +import { useGetGatheringDetailQuery } from '@/src/_queries/detail/gathering-detail-queries'; +import { ApiError } from '@/src/utils/api'; import GatheringDetailModalContainer from '@/src/app/(crew)/crew/_components/gathering-detail-modal/container'; -import { GatheringDetailType, GatheringType } from '@/src/types/gathering-data'; +import Toast from '@/src/components/common/toast'; +import { GatheringType } from '@/src/types/gathering-data'; import GatheringCardPresenter from './presenter'; interface GatheringCardContainerProps extends GatheringType { className?: string; + crewId: number; } export default function GatheringCard({ @@ -21,10 +24,11 @@ export default function GatheringCard({ imageUrl, liked: initialIsLiked, className, + crewId, }: GatheringCardContainerProps) { const [opened, { open, close }] = useDisclosure(false); - const [gatheringData, setGatheringData] = useState(null); - const [error, setError] = useState(false); + // 임시 찜하기 + const [isLiked, setIsLiked] = useState(initialIsLiked); // 날짜 비교 const gatheringDate = new Date(dateTime); @@ -38,35 +42,34 @@ export default function GatheringCard({ // 마감 시간 문자열 생성 const deadlineMessage = `오늘 ${gatheringDate.getHours()}시 마감`; - // 임시 찜하기 - const [isLiked, setIsLiked] = useState(initialIsLiked); - // 추후 찜하기 컴포넌트 작성되면 수정 const handleLikeToggle = () => { setIsLiked((prev) => !prev); }; - const openModal = () => { - // TODO: 모임 상세보기 API 연결 - open(); - }; + const { data: gatheringData, error } = useGetGatheringDetailQuery(crewId, id); - // Fix: 추후 수정 useEffect(() => { - const fetchGatheringDetail = async () => { - setError(false); - try { - const data = await getGathering(); - setGatheringData(data); - } catch { - setError(true); - } - }; + if (error) { + if (error instanceof ApiError) { + try { + const errorData = JSON.parse(error.message); - if (opened) { - fetchGatheringDetail(); + if (errorData.status === 'NOT_FOUND') { + Toast({ message: '모임 정보를 찾을 수 없습니다.', type: 'error' }); + } + } catch { + Toast({ message: `Error ${error.status}: ${error.message}`, type: 'error' }); + } + } else { + Toast({ message: '데이터 통신에 실패했습니다.', type: 'error' }); + } } - }, [opened]); + }, [error]); + + const openModal = () => { + open(); + }; return ( <> diff --git a/src/components/common/gathering-card/gathering-card.stories.tsx b/src/components/common/gathering-card/gathering-card.stories.tsx index 1e2924dc..e8a4dfc8 100644 --- a/src/components/common/gathering-card/gathering-card.stories.tsx +++ b/src/components/common/gathering-card/gathering-card.stories.tsx @@ -1,6 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GatheringCard from './container'; +const queryClient = new QueryClient(); + const meta: Meta = { title: 'Components/GatheringCard', component: GatheringCard, @@ -22,16 +25,22 @@ const meta: Meta = { liked: { control: 'boolean' }, className: { control: false }, }, + decorators: [ + (Story) => ( + + + + ), + ], } satisfies Meta; export default meta; type Story = StoryObj; -// 기본 설정: 기본 모임 카드 export const Default: Story = { args: { title: '가나다라마가나다라마가나다라마가', - dateTime: '2024-10-30T00:30', + dateTime: '2024-11-30T00:30', location: '서울, 한강공원', currentCount: 8, totalCount: 12, @@ -45,7 +54,7 @@ export const Default: Story = { export const NotLiked: Story = { args: { title: '등산 모임', - dateTime: '2024-11-12T09:00', + dateTime: '2024-12-12T09:00', location: '서울, 한강공원', currentCount: 5, totalCount: 10, diff --git a/src/components/common/gathering-card/presenter.tsx b/src/components/common/gathering-card/presenter.tsx index 1fab527e..79079d6f 100644 --- a/src/components/common/gathering-card/presenter.tsx +++ b/src/components/common/gathering-card/presenter.tsx @@ -1,7 +1,7 @@ import { MouseEvent } from 'react'; import Image from 'next/image'; import { Badge } from '@mantine/core'; -import { cn } from '@/src/hooks/cn'; +import { cn } from '@/src/utils/cn'; import { formatDate } from '@/src/utils/format-date'; import Button from '@/src/components/common/input/button'; import LikeBtn from '@/src/components/common/input/button/like-btn'; diff --git a/src/components/gathering-list/gathering-card-carousel.tsx b/src/components/gathering-list/gathering-card-carousel.tsx index c9033e18..365e8edf 100644 --- a/src/components/gathering-list/gathering-card-carousel.tsx +++ b/src/components/gathering-list/gathering-card-carousel.tsx @@ -9,9 +9,13 @@ import IcoRight from '@/public/assets/icons/ic-right.svg'; interface GatheringCardCarouselProps { gatheringData: GatheringType[]; + crewId: number; } -export default function CustomGatheringCardCarousel({ gatheringData }: GatheringCardCarouselProps) { +export default function CustomGatheringCardCarousel({ + gatheringData, + crewId, +}: GatheringCardCarouselProps) { const [currentIndex, setCurrentIndex] = useState(0); const [slidesToShow, setSlidesToShow] = useState(1); const [slideSize, setSlideSize] = useState('w-full'); @@ -27,7 +31,7 @@ export default function CustomGatheringCardCarousel({ gatheringData }: Gathering newSlideSize = 'w-full'; } else if (screenWidth <= 1200) { newSlidesToShow = 2; - newSlideSize = 'w-[calc(50%-8px)]'; + newSlideSize = 'w-[calc(50%-8px)]'; // 두 개일 경우 } else { newSlidesToShow = 3; newSlideSize = 'w-[calc(33.33%-12px)]'; @@ -56,7 +60,7 @@ export default function CustomGatheringCardCarousel({ gatheringData }: Gathering }; return ( -
+
{gatheringData.map((card) => ( -
- +
+
))}
@@ -102,7 +106,7 @@ export default function CustomGatheringCardCarousel({ gatheringData }: Gathering )} {/* Custom Indicators */} -
+
{Array.from({ length: Math.ceil(totalSlides / slidesToShow) }).map((_, i) => ( = { title: 'Components/GatheringCardCarousel', component: GatheringCardCarousel, @@ -45,6 +48,13 @@ const meta: Meta = { layout: 'fullscreen', }, tags: ['autodocs'], + decorators: [ + (Story) => ( + + + + ), + ], } satisfies Meta; export default meta; @@ -54,5 +64,6 @@ type Story = StoryObj; export const Default: Story = { args: { gatheringData: mockGatheringData, + crewId: 1, // crewId를 기본값으로 설정 }, }; diff --git a/src/components/gathering-list/gathering-list.tsx b/src/components/gathering-list/gathering-list.tsx index 3f6375b5..e47de946 100644 --- a/src/components/gathering-list/gathering-list.tsx +++ b/src/components/gathering-list/gathering-list.tsx @@ -6,6 +6,8 @@ import { useMediaQuery } from '@mantine/hooks'; import GatheringCard from '@/src/components/common/gathering-card/container'; import { GatheringType } from '@/src/types/gathering-data'; +// TODO: 수정이 많이 들어갈 예정이라 다른 브랜치에서 수정예정 + /* eslint-disable react/no-array-index-key */ interface GatheringListProps { @@ -53,9 +55,9 @@ export default function GatheringList({ gatheringData }: GatheringListProps) { return (
- {currentPageData.map((card, id) => ( + {/* {currentPageData.map((card, id) => ( - ))} + ))} */} {/* 빈 카드를 추가하여 페이지네이션 위치를 고정 */} {isDesktop && totalCards < 4 && renderEmptyCards(4 - totalCards, 'w-[380px]')} diff --git a/src/utils/api.ts b/src/utils/api.ts index 4ca84740..c6b038db 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -35,7 +35,8 @@ export async function fetchApi( }; try { - const response = await fetch(`${url}`, fetchOptions); // API 요청 실행 + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${url}`, fetchOptions); + if (!response.ok) { let errorDetail; let errorMessage; @@ -50,8 +51,7 @@ export async function fetchApi( throw new ApiError(response.status, errorMessage, errorDetail); } - const data = await response.json(); - return { ...data, headers: response.headers } as T; + return { data: await response.json() } as T; } catch (error) { if (error instanceof Error) { if (error.name === 'AbortError') throw new ApiError(408, 'Request timeout'); diff --git a/src/hooks/cn.ts b/src/utils/cn.ts similarity index 100% rename from src/hooks/cn.ts rename to src/utils/cn.ts