diff --git a/public/pin.svg b/public/pin.svg new file mode 100644 index 0000000..e1b54be --- /dev/null +++ b/public/pin.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/entities/club-detail/ui/club-detail-tabs.tsx b/src/entities/club-detail/ui/club-detail-tabs.tsx index fbf6024..137378d 100644 --- a/src/entities/club-detail/ui/club-detail-tabs.tsx +++ b/src/entities/club-detail/ui/club-detail-tabs.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import ClubRecruitWidget from '@/widgets/club-detail/ui/club-recruit-widget'; import ClubDescriptionWidget from '@/widgets/club-detail/ui/club-description-widget'; import ClubCommentsWidget from '@/widgets/club-detail/ui/club-comments-widget'; +import { ClubRecruitments } from '@/views/club/model/type'; interface RecruitDetailViewProps { title: string; @@ -13,13 +14,16 @@ interface RecruitDetailViewProps { recruitStart?: string; recruitEnd?: string; clubId: number; + id: number; } interface ClubDetailTabsProps { activeTab: string; isManageClub: boolean; recruitData: RecruitDetailViewProps; + recruitHistories: ClubRecruitments[]; id: number; + rid: number; } const TABS = [ @@ -32,18 +36,15 @@ function ClubDetailTabs({ activeTab, isManageClub, recruitData, + recruitHistories, id, + rid, }: ClubDetailTabsProps) { const getHref = (key: string) => { - switch (key) { - case 'about': - return `/club/${id}?tab=about`; - case 'comments': - return `/club/${id}?tab=comments`; - case 'recruit': - default: - return `/club/${id}`; - } + const queryString = new URLSearchParams(); + queryString.set('rid', String(rid)); + if (key !== 'recruit') queryString.set('tab', key); + return `/club/${id}?${queryString.toString()}`; }; const renderContent = () => { @@ -53,15 +54,19 @@ function ClubDetailTabs({ return ( ); } diff --git a/src/entities/club-detail/ui/recruit-detail-header.tsx b/src/entities/club-detail/ui/recruit-detail-header.tsx index 3e569ee..9ae186b 100644 --- a/src/entities/club-detail/ui/recruit-detail-header.tsx +++ b/src/entities/club-detail/ui/recruit-detail-header.tsx @@ -34,10 +34,12 @@ function RecruitDetailHeader({ return ( <>
-
+
-

{title}

-

+

+ {title} +

+

@@ -46,12 +48,15 @@ function RecruitDetailHeader({

- +
diff --git a/src/entities/club-detail/ui/recruit-detail-view.tsx b/src/entities/club-detail/ui/recruit-detail-view.tsx index 87c16af..1a129cb 100644 --- a/src/entities/club-detail/ui/recruit-detail-view.tsx +++ b/src/entities/club-detail/ui/recruit-detail-view.tsx @@ -55,7 +55,7 @@ function RecruitDetailView({ 동아리 지원하러 가기:
diff --git a/src/entities/club-detail/ui/recruit-history-card.tsx b/src/entities/club-detail/ui/recruit-history-card.tsx new file mode 100644 index 0000000..1eb62b7 --- /dev/null +++ b/src/entities/club-detail/ui/recruit-history-card.tsx @@ -0,0 +1,30 @@ +import { ClubRecruitments } from '@/views/club/model/type'; + +interface RecruitHistoryCardProps { + recruitHistories: ClubRecruitments; + isSelected: boolean; +} + +function RecruitHistoryCard({ + recruitHistories, + isSelected, +}: RecruitHistoryCardProps) { + const formatDateDot = (iso: string) => + iso ? iso.slice(0, 10).replaceAll('-', '.') : ''; + + return ( +
+ + {formatDateDot(recruitHistories.createdAt)} + + + {recruitHistories.title} + +
+ ); +} + +export default RecruitHistoryCard; diff --git a/src/entities/club-detail/ui/recruit-history-section.tsx b/src/entities/club-detail/ui/recruit-history-section.tsx new file mode 100644 index 0000000..4cb0d73 --- /dev/null +++ b/src/entities/club-detail/ui/recruit-history-section.tsx @@ -0,0 +1,106 @@ +import { ClubRecruitments } from '@/views/club/model/type'; +import Link from 'next/link'; +import { useState, useEffect } from 'react'; +import RecruitHistoryCard from './recruit-history-card'; + +interface RecruitHistorySectionProps { + clubId: number; + recruitHistories: ClubRecruitments[]; + selectedRid: number; +} + +function useRecruitHistoryVisibleCardCount() { + const [cardCount, setCardCount] = useState(1); + + useEffect(() => { + const update = () => { + const w = window.innerWidth; + + if (w >= 1024) setCardCount(3); + else if (w >= 640) setCardCount(2); + else setCardCount(1); + }; + + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + return cardCount; +} + +function RecruitHistorySection({ + clubId, + recruitHistories, + selectedRid, +}: RecruitHistorySectionProps) { + const list = Array.isArray(recruitHistories) ? recruitHistories : []; + const visibleCardCount = useRecruitHistoryVisibleCardCount(); + + const [index, setIndex] = useState(0); + const maxIndex = Math.max(0, list.length - visibleCardCount); + + const canPrev = index > 0; + const canNext = index < maxIndex; + + const itemWidth = `${100 / visibleCardCount}%`; + + return ( + <> +
+ pin + 전체 모집 공고 +
+ +
+
+ {list.map((r) => { + const queryString = new URLSearchParams(); + queryString.set('rid', String(r.id)); + const href = `/club/${clubId}?${queryString.toString()}`; + + return ( +
+ + + +
+ ); + })} +
+ +
+ + + +
+
+ + ); +} + +export default RecruitHistorySection; diff --git a/src/views/club/api/getClubRecruitments.tsx b/src/views/club/api/getClubRecruitments.tsx new file mode 100644 index 0000000..50f97f1 --- /dev/null +++ b/src/views/club/api/getClubRecruitments.tsx @@ -0,0 +1,28 @@ +import ErrorHandler from '@/shared/lib/error-message'; +import { ApiResponse } from '@/shared/model/type'; +import api from '@/shared/api/auth-api'; +import { auth } from '@/auth'; +import serverApi from '@/shared/api/server-api'; +import { ClubRecruitmentsResponse } from '../model/type'; + +async function getClubRecruitments(clubId: number) { + const session = await auth(); + try { + let response: ApiResponse; + if (session?.accessToken) { + response = await api.get(`recruitments/club/${clubId}`).json(); + } else { + response = await serverApi + .get(`recruitments/club/${clubId}`, { + cache: 'force-cache', + next: { revalidate: 3600 }, + }) + .json(); + } + return { ok: true, data: response.data, status: 200 }; + } catch (e) { + return ErrorHandler(e as Error); + } +} + +export default getClubRecruitments; diff --git a/src/views/club/api/getRecruitDetail.ts b/src/views/club/api/getRecentRecruitDetail.ts similarity index 89% rename from src/views/club/api/getRecruitDetail.ts rename to src/views/club/api/getRecentRecruitDetail.ts index 7679662..fafebd4 100644 --- a/src/views/club/api/getRecruitDetail.ts +++ b/src/views/club/api/getRecentRecruitDetail.ts @@ -5,7 +5,7 @@ import { auth } from '@/auth'; import serverApi from '@/shared/api/server-api'; import { RecruitmentDetail } from '../model/type'; -async function getRecruitDetail(id: number) { +async function getRecentRecruitDetail(id: number) { const session = await auth(); try { let response: ApiResponse; @@ -25,4 +25,4 @@ async function getRecruitDetail(id: number) { } } -export default getRecruitDetail; +export default getRecentRecruitDetail; diff --git a/src/views/club/api/getRecruitDetail.tsx b/src/views/club/api/getRecruitDetail.tsx new file mode 100644 index 0000000..896136f --- /dev/null +++ b/src/views/club/api/getRecruitDetail.tsx @@ -0,0 +1,28 @@ +import ErrorHandler from '@/shared/lib/error-message'; +import { ApiResponse } from '@/shared/model/type'; +import api from '@/shared/api/auth-api'; +import { auth } from '@/auth'; +import serverApi from '@/shared/api/server-api'; +import { RecruitmentDetail } from '../model/type'; + +async function getRecruitDetail(recruitmentId: number) { + const session = await auth(); + try { + let response: ApiResponse; + if (session?.accessToken) { + response = await api.get(`recruitments/${recruitmentId}`).json(); + } else { + response = await serverApi + .get(`recruitments/${recruitmentId}`, { + cache: 'force-cache', + next: { revalidate: 3600 }, + }) + .json(); + } + return { ok: true, data: response.data, status: 200 }; + } catch (e) { + return ErrorHandler(e as Error); + } +} + +export default getRecruitDetail; diff --git a/src/views/club/model/type.ts b/src/views/club/model/type.ts index 6fca70d..e00d647 100644 --- a/src/views/club/model/type.ts +++ b/src/views/club/model/type.ts @@ -35,8 +35,25 @@ export interface RecruitmentDetail { category: string; clubName: string; logo: string; + isAlwaysRecruiting: boolean; } export interface RecruitmentDetailResponse { data: RecruitmentDetail; } + +export interface ClubRecruitments { + id: number; + title: string; + content: string; + recruitStart: string; + recruitEnd: string; + status: RecruitStatus; + createdAt: string; + firstImage?: string; + isAlwaysRecruiting: boolean; +} + +export interface ClubRecruitmentsResponse { + recruitments: ClubRecruitments[]; +} diff --git a/src/views/club/ui/club-detail-page.tsx b/src/views/club/ui/club-detail-page.tsx index 3bcbbb7..bff5cc4 100644 --- a/src/views/club/ui/club-detail-page.tsx +++ b/src/views/club/ui/club-detail-page.tsx @@ -1,60 +1,79 @@ -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import RecruitDetailHeader from '@/entities/club-detail/ui/recruit-detail-header'; import getClubManageInfo from '@/shared/api/manage-api'; import ErrorBoundaryUi from '@/shared/ui/error-boundary-ui'; import { auth } from '@/auth'; import ClubDetailTabs from '@/entities/club-detail/ui/club-detail-tabs'; -import getRecruitDetail from '@/views/club/api/getRecruitDetail'; +import getRecentRecruitDetail from '@/views/club/api/getRecentRecruitDetail'; +import getClubRecruitments from '../api/getClubRecruitments'; +import getRecruitDetail from '../api/getRecruitDetail'; interface ClubDetailPageProps { params: Promise<{ id: string }>; - searchParams: Promise<{ tab: string }>; + searchParams: Promise<{ tab?: string; rid?: string }>; } async function ClubDetailPage({ params, searchParams }: ClubDetailPageProps) { const { id } = await params; - const tab = (await searchParams).tab || 'recruit'; + const { tab = 'recruit', rid } = await searchParams; const session = await auth(); const role = session?.role; - const [getClubManageInfoRes, data] = await Promise.all([ + + const [getClubManageInfoRes, recent, recruitHistories] = await Promise.all([ getClubManageInfo({ role }), - getRecruitDetail(Number(id)), + getRecentRecruitDetail(Number(id)), + getClubRecruitments(Number(id)), ]); - if (data?.status === 404 || !data.data) { - notFound(); - } - - if (!data.ok) { - return ; - } + if (recent?.status === 404 || !recent.data) notFound(); + if (!recent.ok) return ; const isManageClub = getClubManageInfoRes?.data?.clubs.some( - (club) => club.clubId === data.data?.clubId, + (club) => club.clubId === recent.data?.clubId, ) || false; + const historiesArray = recruitHistories.ok + ? (recruitHistories.data?.recruitments ?? []) + : []; + + if (!(await searchParams).rid) { + const queryString = new URLSearchParams(); + queryString.set('rid', String(recent.data.id)); + if (tab !== 'recruit') queryString.set('tab', tab); + redirect(`/club/${id}?${queryString.toString()}`); + } + + const recruitmentId = Number(rid) || recent.data.id; + if (!rid) notFound(); + + const selected = await getRecruitDetail(recruitmentId); + if (selected?.status === 404 || !selected.data) notFound(); + if (!selected.ok) return ; + return (
); diff --git a/src/widgets/club-detail/ui/club-recruit-widget.tsx b/src/widgets/club-detail/ui/club-recruit-widget.tsx index 336692b..eaa4094 100644 --- a/src/widgets/club-detail/ui/club-recruit-widget.tsx +++ b/src/widgets/club-detail/ui/club-recruit-widget.tsx @@ -7,9 +7,10 @@ import { toast } from 'react-toastify'; import ky from 'ky'; import { useRouter } from 'next/navigation'; import deleteRecruitmentForm from '@/widgets/club-detail/api/deleteRecruitment'; +import RecruitHistorySection from '@/entities/club-detail/ui/recruit-history-section'; +import { ClubRecruitments } from '@/views/club/model/type'; -interface ClubRecruitWidgetProps { - isManageClub?: boolean; +interface RecruitDetail { title: string; clubName: string; category: string; @@ -18,22 +19,25 @@ interface ClubRecruitWidgetProps { imageUrls: string[]; recruitStart: string; recruitEnd: string; +} +interface ClubRecruitWidgetProps { + isManageClub?: boolean; + clubId: number; + recruitHistories: ClubRecruitments[]; + rid: number; + recruitDetail: RecruitDetail; } function ClubRecruitWidget({ isManageClub, - title, - clubName, - category, - content, - recruitForm, - imageUrls, - recruitStart, - recruitEnd, clubId, + recruitHistories, + rid, + recruitDetail, }: ClubRecruitWidgetProps) { const [isEditing, setIsEditing] = useState(false); + const router = useRouter(); const handleDelete = async (e: React.MouseEvent) => { @@ -76,24 +80,31 @@ function ClubRecruitWidget({
)} {!isEditing ? ( - + <> + + + ) : ( )}