From 76ffaa870545503f5c4492067842104494b8261e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 27 Feb 2026 04:41:28 +0900 Subject: [PATCH 01/36] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=ED=99=88=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/[id]/dummy.ts | 55 --- src/app/groups/[id]/page.tsx | 441 +++++++++++++----------- src/hooks/queries/useClubhomeQueries.ts | 47 +++ src/lib/api/endpoints/Clubs.ts | 16 + src/services/clubService.ts | 45 +++ src/types/groups/grouphome.ts | 106 +++--- 6 files changed, 412 insertions(+), 298 deletions(-) delete mode 100644 src/app/groups/[id]/dummy.ts create mode 100644 src/hooks/queries/useClubhomeQueries.ts diff --git a/src/app/groups/[id]/dummy.ts b/src/app/groups/[id]/dummy.ts deleted file mode 100644 index 9a935d1..0000000 --- a/src/app/groups/[id]/dummy.ts +++ /dev/null @@ -1,55 +0,0 @@ -// dummy.ts (page.tsx와 같은 폴더) -import type { ClubHomeResponse, ClubHomeResponseResult,ClubModalLink } from '@/types/groups/grouphome'; - - - -export const DUMMY_CLUB_HOME_RESPONSE: ClubHomeResponse = { - isSuccess: true, - code: 'COMMON200', - message: '성공입니다.', - result: { - clubId: 1, - name: '서울 독서모임', - profileImageUrl: null, - region: '서울', - category: [ - { code: 'HUMANITIES', description: '인문학' }, - { code: 'COMPUTER_IT', description: '컴퓨터/IT' }, - { code: 'ESSAY', description: '에세이' }, - { code: 'HISTORY_CULTURE', description: '역사/문화' }, - { code: 'COMPUTER_IT', description: '정치/외교/국방' }, - { code: 'HISTORY_CULTURE', description: '어린이/청소년' }, - ], - participantTypes: [ - { code: 'OFFLINE', description: '대면' }, - { code: 'WORKER', description: '직장인' }, - { code: 'STUDENT', description: '대학생' }, - ], - open: true, - - description: - '책을 좋아하는 사람들이 모여 각자의 속도로 읽고, 각자의 언어로 생각을 나누는 책 모임입니다. 정답을 찾기보다 질문을 남기는 시간을 소중히 여기며, 한 권의 책을 통해 서로의 관점과 경험을 자연스럽게 공유하는 것을 목표로 합니다.', - recentNotice: { - noticeId: 24, - title: '공지사항_미리보기', - createdAt: '2026-02-02T00:00:00.000Z', - url: '/groups/1/notices/24', - }, - links: { - joinUrl: '/groups/1/join', - contactUrl: '/contact', - }, - - modalLinks: [ - { id: 1, url: 'https://instagram.com/seoul_bookclub' }, - { id: 2, url: 'https://open.kakao.com/o/g0AbCDeF' }, - { id: 3, url: 'https://forms.gle/8YqZpZkQkQ2nH9rK9' }, - ], - - // 운영진 여부 (true: 운영진, false: 일반 회원) - isAdmin: true, - }, -}; - - -export const DUMMY_CLUB_HOME: ClubHomeResponseResult = DUMMY_CLUB_HOME_RESPONSE.result; diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 3d5be6f..57733d0 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -1,54 +1,125 @@ -'use client'; +"use client"; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter, useParams } from 'next/navigation'; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter, useParams } from "next/navigation"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; -import { useState } from 'react'; +import ClubCategoryTags from "@/components/base-ui/Group-Search/search_clublist/search_club_category_tags"; +import ButtonWithoutImg from "@/components/base-ui/button_without_img"; +import GroupAdminMenu from "@/components/base-ui/Group/group_admin_menu"; +import { useClubhomeQueries } from "@/hooks/queries/useClubhomeQueries"; -import ClubCategoryTags from '@/components/base-ui/Group-Search/search_clublist/search_club_category_tags'; -import { BOOK_CATEGORIES } from '@/types/groups/groups'; -import ButtonWithoutImg from '@/components/base-ui/button_without_img'; -import type { ClubModalLink } from '@/types/groups/grouphome'; -import { DUMMY_CLUB_HOME } from './dummy'; -import GroupAdminMenu from '@/components/base-ui/Group/group_admin_menu'; +const DEFAULT_CLUB_IMG = "/ClubDefaultImg.svg"; - -const DEFAULT_CLUB_IMG = '/ClubDefaultImg.svg'; - -export default function AdminGroupHomePage() { +export default function GroupDetailPage() { const router = useRouter(); const params = useParams(); - const groupId = params.id as string; - - const noticeText = DUMMY_CLUB_HOME.recentNotice?.title ?? '공지사항이 없습니다.'; - const noticeUrl = DUMMY_CLUB_HOME.recentNotice?.url ?? '/groups'; - - const imgSrc = DUMMY_CLUB_HOME.profileImageUrl ?? DEFAULT_CLUB_IMG; - const clubName = DUMMY_CLUB_HOME.name; + const groupId = Number(params.id); - const joinUrl = DUMMY_CLUB_HOME.links?.joinUrl ?? '/groups'; - const contactUrl = DUMMY_CLUB_HOME.links?.contactUrl ?? '/contact'; + const { meQuery, homeQuery, latestNoticeQuery, nextMeetingQuery } = useClubhomeQueries(groupId); - const participantText = DUMMY_CLUB_HOME.participantTypes - .map((p: { description: string }) => p.description) - .join(', '); + const isLoading = + meQuery.isLoading || homeQuery.isLoading || latestNoticeQuery.isLoading || nextMeetingQuery.isLoading; - const nums = DUMMY_CLUB_HOME.category - .map((c: { description: string }) => BOOK_CATEGORIES.indexOf(c.description as never) + 1) - .filter((n: number) => n >= 1 && n <= 15); + const isError = meQuery.isError || homeQuery.isError; const [isContactOpen, setIsContactOpen] = useState(false); - const [isAdmin, setIsAdmin] = useState(true); + + if (!Number.isFinite(groupId) || groupId <= 0) { + return ( +
+
+ 잘못된 모임 ID +
+
+ ); + } + + if (isLoading) { + return ( +
+
+
불러오는 중...
+
+
+ ); + } + + if (isError) { + return ( +
+
+
모임 정보를 불러오지 못했습니다.
+
+
+ ); + } + + const me = meQuery.data!; + const home = homeQuery.data!; + const latestNotice = latestNoticeQuery.data; // null/undefined 가능 + const nextMeeting = nextMeetingQuery.data; // null/undefined 가능 + + const isAdmin = me.staff === true; + + const noticeText = latestNotice?.title ?? "공지사항이 없습니다."; + const hasNotice = Boolean(latestNotice?.id); + const noticeUrl = `/groups/${groupId}/notice`; + + const imgSrc = home.profileImageUrl || DEFAULT_CLUB_IMG; + const clubName = home.name; + + const participantText = (home.participantTypes ?? []).map((p) => p.description).join(", "); + + const joinUrl = nextMeeting?.redirectUrl ?? null; + + // home.links는 [{link,label}] 형식이라 기존 UI의 contactUrl/modalLinks랑 1:1 매칭이 없음 + // UI 깨지지 않게: 링크 목록을 contact 모달에 그냥 뿌리는 방식으로 연결 + const modalLinks = useMemo(() => { + const list = home.links ?? []; + return list + .map((x, idx) => ({ + id: `${idx}`, + url: x.link, + label: x.label, + })) + .filter((x) => (x.url ?? "").trim().length > 0); + }, [home.links]); + + const onClickJoin = () => { + if (!joinUrl) { + toast.error("다음 정기모임이 없습니다."); + return; + } + router.push(joinUrl); + }; return (
- {/* 최대 1024, d에서만 px-10(40px) */} + {/* ✅ 원래 UI 그대로: max 1024, t px-3, d px-0 */}
- {/* 1) 공지 (항상 최상단) */} - { + if (!hasNotice) { + toast.error("공지사항이 없습니다."); + return; + } + router.push(`/groups/${groupId}/notice`); + }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + if (!hasNotice) { + toast.error("공지사항이 없습니다."); + return; + } + router.push(`/groups/${groupId}/notice`); + }} className=" block w-full rounded-[8px] @@ -56,14 +127,13 @@ export default function AdminGroupHomePage() { bg-White p-4 cursor-pointer - hover:brightness-98 hover:-translate-y-[1px] cursor-pointer + hover:brightness-98 hover:-translate-y-[1px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-Subbrown-2 " - aria-label="공지사항으로 이동" + aria-label="공지사항" >
- {/* 종 박스 */}
- {/* 공지 텍스트 */}

{noticeText}

- +
+ {/* 본문 */}
- - {/* Desktop (d): 이미지 | (텍스트 + 버튼은 같은 컬럼, 버튼은 글 하단) */} + {/* ✅ Desktop (d): 원래 2컬럼 구조 그대로 */}
{/* 2) 이미지 */}
- {`${clubName} + {`${clubName}
{/* 3) 텍스트 + 4) 버튼 (같은 컬럼) */} @@ -114,25 +176,25 @@ export default function AdminGroupHomePage() { )}
- + {/* ✅ category는 DTO 그대로(라벨 보여주기) */} +
-

모임 대상

-

{participantText}

+

{participantText || "-"}

활동 지역

-

{DUMMY_CLUB_HOME.region ?? '-'}

+

{home.region ?? "-"}

- {DUMMY_CLUB_HOME.description ?? '설명이 없습니다.'} + {home.description ?? "설명이 없습니다."}

@@ -140,15 +202,23 @@ export default function AdminGroupHomePage() {
router.push(`${Number(groupId)}/notice/4`)} + onClick={onClickJoin} bgColorVar="--Primary_1" borderColorVar="--Primary_1" textColorVar="--White" className="w-[300px] h-[44px] body_1 hover:brightness-90 hover:-translate-y-[1px] cursor-pointer" /> + + {/* 링크가 있으면 Contact 활성, 없으면 toast */} setIsContactOpen(true)} + onClick={() => { + if (modalLinks.length === 0) { + toast.error("연락처/링크 정보가 없습니다."); + return; + } + setIsContactOpen(true); + }} bgColorVar="--Subbrown_4" borderColorVar="--Subbrown_2" textColorVar="--Primary_3" @@ -158,166 +228,145 @@ export default function AdminGroupHomePage() {
+ {/* ✅ Mobile/Tablet: 원래 구조 그대로 */} +
+
+ {/* 2) 이미지 */} +
+ {`${clubName} +
-
- -
- {/* 2) 이미지 */} -
- {`${clubName} -
+ {/* 3) 내용 */} +
+ {isAdmin && ( +
+ +
+ )} - {/* 3) 내용 */} -
- {isAdmin && ( -
- +
+
- )} -
- -
+
+
+

모임 대상

+

{participantText || "-"}

+
-
-
-

모임 대상

-

{participantText}

+
+

활동 지역

+

{home.region ?? "-"}

+
-
-

활동 지역

-

{DUMMY_CLUB_HOME.region ?? '-'}

+
+

+ {home.description ?? "설명이 없습니다."} +

- -
-

- {DUMMY_CLUB_HOME.description ?? '설명이 없습니다.'} -

-
-
- {/* 버튼 태블릿/모바일 (전체 하단) */} -
- router.push(joinUrl)} - bgColorVar="--Primary_1" - borderColorVar="--Primary_1" - textColorVar="--White" - className="w-full d:w-[300px] h-[44px] body_1 hover:brightness-90 hover:-translate-y-[1px] cursor-pointer" - /> - setIsContactOpen(true)} - bgColorVar="--Subbrown_4" - borderColorVar="--Subbrown_2" - textColorVar="--Primary_3" - className="w-full d:w-[300px] h-[44px] body_1 hover:brightness-95 hover:-translate-y-[1px] cursor-pointer" - /> + {/* 버튼 태블릿/모바일 (전체 하단) */} +
+ + + { + if (modalLinks.length === 0) { + toast.error("연락처/링크 정보가 없습니다."); + return; + } + setIsContactOpen(true); + }} + bgColorVar="--Subbrown_4" + borderColorVar="--Subbrown_2" + textColorVar="--Primary_3" + className="w-full d:w-[300px] h-[44px] body_1 hover:brightness-95 hover:-translate-y-[1px] cursor-pointer" + /> +
-
-
- - {/* Contact Modal */} - {isContactOpen && ( -
setIsContactOpen(false)} - role="dialog" - aria-modal="true" - > - {/* 모달 박스 (바깥 클릭 닫힘 방지) */} +
+ + {/* Contact Modal (원래 UI 그대로, 데이터만 home.links로 채움) */} + {isContactOpen && (
e.stopPropagation()} + onClick={() => setIsContactOpen(false)} + role="dialog" + aria-modal="true" > - {/* 헤더: 타이틀 + X */} -
-

Contact Us

- - -
- - {/* 리스트 */} -
-
- )} - + )}
); } \ No newline at end of file diff --git a/src/hooks/queries/useClubhomeQueries.ts b/src/hooks/queries/useClubhomeQueries.ts new file mode 100644 index 0000000..2824903 --- /dev/null +++ b/src/hooks/queries/useClubhomeQueries.ts @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { clubService } from "@/services/clubService"; + +const clubhomeKeys = { + all: (clubId: number) => ["clubhome", clubId] as const, + me: (clubId: number) => [...clubhomeKeys.all(clubId), "me"] as const, + home: (clubId: number) => [...clubhomeKeys.all(clubId), "home"] as const, + latestNotice: (clubId: number) => [...clubhomeKeys.all(clubId), "latestNotice"] as const, + nextMeeting: (clubId: number) => [...clubhomeKeys.all(clubId), "nextMeeting"] as const, +}; + +export function useClubhomeQueries(clubId: number) { + const enabled = Number.isFinite(clubId) && clubId > 0; + + const meQuery = useQuery({ + queryKey: clubhomeKeys.me(clubId), + queryFn: () => clubService.getMyStatus(clubId), + enabled, + }); + + const homeQuery = useQuery({ + queryKey: clubhomeKeys.home(clubId), + queryFn: () => clubService.getClubHome(clubId), + enabled, + }); + + const latestNoticeQuery = useQuery({ + queryKey: clubhomeKeys.latestNotice(clubId), + queryFn: () => clubService.getLatestNotice(clubId), + enabled, + retry: false, + }); + + const nextMeetingQuery = useQuery({ + queryKey: clubhomeKeys.nextMeeting(clubId), + queryFn: () => clubService.getNextMeeting(clubId), + enabled, + retry: false, + }); + + return { + meQuery, + homeQuery, + latestNoticeQuery, + nextMeetingQuery, + }; +} \ No newline at end of file diff --git a/src/lib/api/endpoints/Clubs.ts b/src/lib/api/endpoints/Clubs.ts index 3a5d995..a6a06f6 100644 --- a/src/lib/api/endpoints/Clubs.ts +++ b/src/lib/api/endpoints/Clubs.ts @@ -7,4 +7,20 @@ export const CLUBS = { recommendations: `${API_BASE_URL}/clubs/recommendations`, search: `${API_BASE_URL}/clubs/search`, join: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/join`, + + // 나의 상태 조회: GET /api/clubs/{clubId}/me + me: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/me`, + + // 모임 홈: GET /api/clubs/{clubId}/home + home: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/home`, + + // 최신 공지 1개: GET /api/clubs/{clubId}/notices/latest + latestNotice: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/notices/latest`, + + // 이번 모임 바로가기: GET /api/clubs/{clubId}/meetings/next + nextMeeting: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/meetings/next`, + + members: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/members`, // GET + member: (clubId: number, clubMemberId: number) => + `${API_BASE_URL}/clubs/${clubId}/members/${clubMemberId}`, // PATCH } as const; \ No newline at end of file diff --git a/src/services/clubService.ts b/src/services/clubService.ts index 2263f0b..5fee1ce 100644 --- a/src/services/clubService.ts +++ b/src/services/clubService.ts @@ -4,6 +4,7 @@ import { CLUBS } from "@/lib/api/endpoints/Clubs"; import type { ApiResponse } from "@/lib/api/types"; import { CreateClubRequest } from "@/types/groups/clubCreate"; import { ClubJoinRequest, ClubJoinResponse, ClubSearchParams, ClubSearchResponse, MyClubsResponse, RecommendationsResponse } from "@/types/groups/clubsearch"; +import { ClubHomeResponse, ClubHomeResponseResult, LatestNoticeResponse, LatestNoticeResponseResult, MyClubStatusResponse, MyClubStatusResponseResult, NextMeetingResponse, NextMeetingResponseResult } from "@/types/groups/grouphome"; export const clubService = { @@ -32,6 +33,7 @@ export const clubService = { }, searchClubs: async (params: ClubSearchParams) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const cleaned: any = { ...params }; if (cleaned.cursorId == null) delete cleaned.cursorId; if (typeof cleaned.keyword === "string" && cleaned.keyword.trim() === "") { @@ -52,5 +54,48 @@ export const clubService = { ); return res.result; }, + + // GET /api/clubs/{clubId}/me + getMyStatus: async (clubId: number): Promise => { + const res = await apiClient.get(CLUBS.me(clubId)); + return res.result; + }, + + // GET /api/clubs/{clubId}/home + getClubHome: async (clubId: number): Promise => { + const res = await apiClient.get(CLUBS.home(clubId)); + return res.result; + }, + + getLatestNotice: async (clubId: number) => { + try { + const res = await apiClient.get(CLUBS_ENDPOINTS.latestNotice(clubId)); + return res.result; + } catch (e: any) { + const msg = e?.message ?? ""; + if ( + msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지")) + ) { + return null; + } + throw e; + } +}, + +getNextMeeting: async (clubId: number) => { + try { + const res = await apiClient.get(CLUBS_ENDPOINTS.nextMeeting(clubId)); + return res.result; + } catch (e: any) { + const msg = e?.message ?? ""; + if (msg.includes("다음 정기모임이 존재하지 않습니다")) { + return null; + } + if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) { + return null; + } + throw e; + } +}, }; diff --git a/src/types/groups/grouphome.ts b/src/types/groups/grouphome.ts index a0c04ca..8e5fe65 100644 --- a/src/types/groups/grouphome.ts +++ b/src/types/groups/grouphome.ts @@ -1,72 +1,84 @@ -export type ClubCategoryCode = - | 'HUMANITIES' - | 'COMPUTER_IT' - | 'ESSAY' - | 'HISTORY_CULTURE' - - | string; - -export interface ClubCategory { - code: ClubCategoryCode; - description: string; +// 공통 응답 포맷 +export interface ApiResponse { + isSuccess: boolean; + code: string; + message: string; + result: T; } -export type ParticipantTypeCode = - | 'OFFLINE' - | 'ONLINE' - | 'WORKER' - | 'STUDENT' - | 'CLUB' - | 'MEETING' - | string; +// 1) 나의 상태 조회: GET /api/clubs/{clubId}/me +export type MyClubStatus = "OWNER" | "MEMBER" | string; -export interface ParticipantType { - code: ParticipantTypeCode; - description: string; +export interface MyClubStatusResponseResult { + clubId: number; + myStatus: MyClubStatus; + active: boolean; + staff: boolean; // 관리자/운영진 여부 (이걸로 isAdmin 판단) } -export interface ClubRecentNotice { - noticeId: number; - title: string; - createdAt: string; // ISO string 추천 - url: string; // 공지 이동 링크 +export type MyClubStatusResponse = ApiResponse; + +// 2) 모임 홈: GET /api/clubs/{clubId}/home +export interface ClubCategory { + code: string; + description: string; } -export interface ClubLinks { - joinUrl: string; - contactUrl?: string; +export interface ParticipantType { + code: string; + description: string; } -export interface ClubModalLink { - id?: number; - url: string; +export interface ClubLinkItem { + link: string; + label: string; } export interface ClubHomeResponseResult { clubId: number; name: string; - profileImageUrl: string | null; - region: string | null; + description: string; + profileImageUrl: string; + region: string; category: ClubCategory[]; participantTypes: ParticipantType[]; + links: ClubLinkItem[]; open: boolean; +} +export type ClubHomeResponse = ApiResponse; - description?: string; - recentNotice?: ClubRecentNotice; - links?: ClubLinks; +// 3) 최신 공지 1개: GET /api/clubs/{clubId}/notices/latest +export interface LatestNoticeResponseResult { + id: number; + title: string; +} - modalLinks?: ClubModalLink[]; +export type LatestNoticeResponse = ApiResponse; - // 운영진 여부 - isAdmin?: boolean; +// 4) 이번 모임 바로가기: GET /api/clubs/{clubId}/meetings/next +export interface NextMeetingResponseResult { + meetingId: number; + redirectUrl: string; } +export type NextMeetingResponse = ApiResponse; -export interface ClubHomeResponse { - isSuccess: boolean; - code: string; - message: string; - result: ClubHomeResponseResult; -} +export const CLUB_CATEGORY_CODE_TO_NUM: Record = { + TRAVEL: 1, + FOREIGN_LANGUAGE: 2, + CHILD_TEEN: 3, + RELIGION_PHILOSOPHY: 4, + FICTION_POETRY_DRAMA: 5, + ESSAY: 6, + HUMANITIES: 7, + SCIENCE: 8, + COMPUTER_IT: 9, + ECONOMY_BUSINESS: 10, + SELF_IMPROVEMENT: 11, + SOCIAL_SCIENCE: 12, + POLITICS_DIPLOMACY_DEFENSE: 13, + HISTORY_CULTURE: 14, + ART_POP_CULTURE: 15, +}; \ No newline at end of file From d1cc5d4080b43aa13988bd8a8e0de7b5cbe293ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 27 Feb 2026 05:18:37 +0900 Subject: [PATCH 02/36] =?UTF-8?q?fix=20:=20=EB=AA=A8=EC=9E=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/page.tsx | 111 ++++---------- .../search_club_category_tags.tsx | 136 +++++++++++++----- .../search_clublist/search_clublist_item.tsx | 5 +- 3 files changed, 128 insertions(+), 124 deletions(-) diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx index 2b9fbff..efb47b7 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -6,18 +6,13 @@ import toast from "react-hot-toast"; import ButtonWithoutImg from "@/components/base-ui/button_without_img"; import SearchGroupSearch from "@/components/base-ui/Group-Search/search_groupsearch"; -import Mybookclub, { - type GroupSummary, -} from "@/components/base-ui/Group-Search/search_mybookclub"; +import Mybookclub, { type GroupSummary } from "@/components/base-ui/Group-Search/search_mybookclub"; -import SearchClubListItem, { - type ClubSummary, -} from "@/components/base-ui/Group-Search/search_clublist/search_clublist_item"; +import SearchClubListItem, { type ClubSummary } from "@/components/base-ui/Group-Search/search_clublist/search_clublist_item"; import SearchClubApplyModal from "@/components/base-ui/Group-Search/search_club_apply_modal"; import type { Category, ParticipantType } from "@/types/groups/groups"; import type { - ClubCategoryDTO, ClubDTO, ClubListItemDTO, ClubSearchParams, @@ -56,62 +51,26 @@ function mapCategoryToOutputFilter(category: Category): OutputFilter { function mapInputFilter(group: boolean, region: boolean): InputFilter | null { if (group && !region) return "NAME"; if (!group && region) return "REGION"; - return null; // 둘 다 선택 or 둘 다 해제 -} - -const CATEGORY_LABEL_TO_NUM: Record = { - 여행: 1, - 외국어: 2, - "어린이/청소년": 3, - "종교/철학": 4, - "소설/시/희곡": 5, - 에세이: 6, - 인문학: 7, - 과학: 8, - "컴퓨터/IT": 9, - "경제/경영": 10, - 자기계발: 11, - 사회과학: 12, - "정치/외교/국방": 13, - "역사/문화": 14, - "예술/대중문화": 15, -}; - -function mapCategories(dto: ClubCategoryDTO[]): number[] { - return dto - .map((c) => { - const byDesc = CATEGORY_LABEL_TO_NUM[c.description]; - if (byDesc) return byDesc; - const n = Number(c.code); - return Number.isFinite(n) ? n : null; - }) - .filter((v): v is number => typeof v === "number" && v >= 1 && v <= 15); + return null; } function mapApplyType(myStatus: string): "No" | "Wait" | "Yes" { - // 안전 기본값: NONE만 No, MEMBER/JOINED류 Yes, 나머지 Wait if (myStatus === "NONE") return "No"; if (myStatus === "MEMBER" || myStatus === "JOINED") return "Yes"; return "Wait"; } -function mapClubDTOToSummary( - club: ClubDTO, - myStatus: string, - reason = "" -): ClubSummary { +function mapClubDTOToSummary(club: ClubDTO, myStatus: string, reason = ""): ClubSummary { return { reason, clubId: club.clubId, name: club.name, profileImageUrl: club.profileImageUrl, - category: mapCategories(club.category), + category: club.category, public: club.open, applytype: mapApplyType(myStatus), region: club.region, - participantTypes: club.participantTypes - .map((p) => p.code as ParticipantType) - .filter(Boolean), + participantTypes: club.participantTypes.map((p) => p.code as ParticipantType).filter(Boolean), }; } @@ -134,21 +93,13 @@ export default function Searchpage() { const [appliedParams, setAppliedParams] = useState | null>(null); - // 추천/검색 모드 판단 const isSearchMode = appliedParams !== null; - // ===== 가입 모달 ===== const [applyClubId, setApplyClubId] = useState(null); - // ===== Queries ===== - const { - data: myClubsData, - isLoading: myClubsLoading, - } = useMyClubsQuery(); - + const { data: myClubsData, isLoading: myClubsLoading } = useMyClubsQuery(); const { data: recData, isLoading: recLoading } = useClubRecommendationsQuery(!isSearchMode); - // 검색은 appliedParams 있을 때만 실행 const { data: searchData, isFetching: searchFetching, @@ -160,11 +111,8 @@ export default function Searchpage() { isSearchMode ); - // ===== Mutation ===== - const { mutateAsync: joinAsync, isPending: joinPending } = - useClubJoinMutation(); + const { mutateAsync: joinAsync, isPending: joinPending } = useClubJoinMutation(); - // ===== UI 데이터 변환 ===== const myGroups: GroupSummary[] = useMemo(() => { const list = myClubsData?.clubList ?? []; return list.map((c) => ({ id: String(c.clubId), name: c.clubName })); @@ -181,8 +129,7 @@ export default function Searchpage() { }, [searchData]); const clubsToRender = isSearchMode ? searchedClubs : recommendationClubs; - const selectedClub = - clubsToRender.find((c) => c.clubId === applyClubId) ?? null; + const selectedClub = clubsToRender.find((c) => c.clubId === applyClubId) ?? null; const sentinelRef = useRef(null); @@ -204,12 +151,9 @@ export default function Searchpage() { return () => io.disconnect(); }, [isSearchMode, hasNextPage, searchFetching, fetchNextPage]); - // ===== Handlers ===== const onClickVisit = (clubId: number) => router.push(`/groups/${clubId}`); - const onClickApply = (clubId: number) => - setApplyClubId((prev) => (prev === clubId ? null : clubId)); - + const onClickApply = (clubId: number) => setApplyClubId((prev) => (prev === clubId ? null : clubId)); const onCloseApply = () => setApplyClubId(null); const onSubmitApply = async (clubId: number, reason: string) => { @@ -228,22 +172,23 @@ export default function Searchpage() { } }; -const onSubmitSearch = () => { - const keyword = q.trim(); + const onSubmitSearch = () => { + const keyword = q.trim(); + + if (!keyword) { + setAppliedParams(null); + return; + } + + setAppliedParams({ + outputFilter: mapCategoryToOutputFilter(category), + inputFilter: mapInputFilter(group, region), + keyword, + }); + + refetchSearch(); + }; - // 검색없으면 추천모드로 - if (!keyword) { - setAppliedParams(null); - return; - } - setAppliedParams({ - outputFilter: mapCategoryToOutputFilter(category), - inputFilter: mapInputFilter(group, region), - keyword: keyword, - }); - - refetchSearch(); -}; return (
- {/* 로딩 중에도 로고 나오게 */} @@ -312,17 +256,14 @@ const onSubmitSearch = () => { ))} - {/* 추천 로딩 */} {!isSearchMode && recLoading && (

불러오는 중…

)} - {/* 검색 무한 스크롤 sentinel */} {isSearchMode &&
}
- {/* 태블릿 이상: 기존 모달 */}
| null | undefined; className?: string; }; const LABEL: Record = { - 1: '여행', - 2: '외국어', - 3: '어린이/청소년', - 4: '종교/철학', - 5: '소설/시/희곡', - 6: '에세이', - 7: '인문학', - 8: '과학', - 9: '컴퓨터/IT', - 10: '경제/경영', - 11: '자기계발', - 12: '사회과학', - 13: '정치/외교/국방', - 14: '역사/문화', - 15: '예술/대중문화', + 1: "여행", + 2: "외국어", + 3: "어린이/청소년", + 4: "종교/철학", + 5: "소설/시/희곡", + 6: "에세이", + 7: "인문학", + 8: "과학", + 9: "컴퓨터/IT", + 10: "경제/경영", + 11: "자기계발", + 12: "사회과학", + 13: "정치/외교/국방", + 14: "역사/문화", + 15: "예술/대중문화", +}; + +const getBgByIndex = (idx: number) => { + // 그냥 보기 좋은 정도로만 분산 + if (idx % 4 === 0) return "bg-Secondary-2"; + if (idx % 4 === 1) return "bg-Secondary-1"; + if (idx % 4 === 2) return "bg-Secondary-3"; + return "bg-Secondary-4"; }; -const getBgByCategory = (n: number) => { - if (n >= 1 && n <= 4) return 'bg-Secondary-2'; - if (n >= 5 && n <= 7) return 'bg-Secondary-1'; - if (n >= 8 && n <= 11) return 'bg-Secondary-3'; - if (n >= 12 && n <= 15) return 'bg-Secondary-4'; - return 'bg-Subbrown-4'; +const getBgByNumberCategory = (n: number) => { + if (n >= 1 && n <= 4) return "bg-Secondary-2"; + if (n >= 5 && n <= 7) return "bg-Secondary-1"; + if (n >= 8 && n <= 11) return "bg-Secondary-3"; + if (n >= 12 && n <= 15) return "bg-Secondary-4"; + return "bg-Subbrown-4"; }; export default function ClubCategoryTags({ category, className }: Props) { - const nums = Array.from(new Set(category)) - .filter((n) => n >= 1 && n <= 15) - .sort((a, b) => a - b); + const list = Array.isArray(category) ? category : []; + + // ✅ number[]로 들어온 경우 (기존) + if (list.length > 0 && typeof list[0] === "number") { + const nums = Array.from(new Set(list as number[])) + .filter((n) => n >= 1 && n <= 15) + .sort((a, b) => a - b); + + if (nums.length === 0) return null; + + return ( +
+ {nums.map((n) => { + const label = LABEL[n] ?? `카테고리${n}`; + const short = label.length <= 2; + + return ( + + {label} + + ); + })} +
+ ); + } + + // ✅ DTO[]로 들어온 경우 (신규): description 전부 보여줌 + const dtoList = list as CategoryDTO[]; + + // code 기준 uniq (code 없으면 description 기준으로라도 uniq) + const unique = Array.from( + new Map( + dtoList + .map((c) => { + const code = c?.code == null ? "" : String(c.code); + const description = c?.description == null ? "" : String(c.description); + return { code, description }; + }) + .filter((x) => (x.code || x.description).trim().length > 0) + .map((x) => [x.code || x.description, x]) + ).values() + ); + + if (unique.length === 0) return null; return (
- {nums.map((n) => { - const label = LABEL[n] ?? `카테고리${n}`; + {unique.map((c, idx) => { + const label = (c.description || c.code || "").trim(); const short = label.length <= 2; return ( {label} @@ -61,4 +125,4 @@ export default function ClubCategoryTags({ category, className }: Props) { })}
); -} +} \ No newline at end of file diff --git a/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx b/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx index 5fb9cf2..a1415ff 100644 --- a/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx +++ b/src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import React from "react"; import ClubCategoryTags from "./search_club_category_tags"; import type { ApplyType, ParticipantType } from "@/types/groups/groups"; - +import type { ClubCategoryDTO } from "@/types/groups/clubsearch"; const DEFAULT_CLUB_IMG = "/ClubDefaultImg.svg"; // participantTypes 한글 매핑 @@ -26,13 +26,12 @@ const APPLY_META: Record< Yes: { label: "가입 됨", icon: "/BrownCheck.svg", labelClass: "text-primary-3" }, }; -// ✅ UI 컴포넌트 계약 타입은 UI 쪽에 둔다 export type ClubSummary = { reason: string; clubId: number; name: string; profileImageUrl?: string | null; - category: number[]; + category: ClubCategoryDTO[]; public: boolean; applytype: ApplyType; region: string; From e143a77720a14182b07efdebc9c9b2e6e89c7687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 27 Feb 2026 05:43:43 +0900 Subject: [PATCH 03/36] =?UTF-8?q?fix=20:=20=EB=AA=A8=EC=9E=84=20=ED=99=88?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=98=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/[id]/page.tsx | 52 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 57733d0..c9b6ec1 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -81,12 +81,17 @@ export default function GroupDetailPage() { const modalLinks = useMemo(() => { const list = home.links ?? []; return list - .map((x, idx) => ({ - id: `${idx}`, - url: x.link, - label: x.label, - })) - .filter((x) => (x.url ?? "").trim().length > 0); + .map((x, idx) => { + const raw = (x.link ?? "").trim(); + if (!raw) return null; + + const url = /^(https?:\/\/)/i.test(raw) ? raw : `http://${raw}`; + + const label = (x.label ?? "").trim() || `링크 ${idx + 1}`; + + return { id: `${idx}`, url, label }; + }) + .filter(Boolean) as { id: string; url: string; label: string }[]; }, [home.links]); const onClickJoin = () => { @@ -346,23 +351,24 @@ export default function GroupDetailPage() {
{modalLinks.map((item) => ( - - -

{item.label ? `${item.label} · ${item.url}` : item.url}

-
- ))} + + ))}
From 69c66c97599f2260c600e437e8e188687943d3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 27 Feb 2026 05:46:56 +0900 Subject: [PATCH 04/36] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mutations/useClubAdminEditMutations.ts | 24 +++++++ src/hooks/mutations/useClubMemberMutations.ts | 29 ++++++++ src/hooks/queries/useClubAdminEditQueries.ts | 17 +++++ src/hooks/queries/useClubMemberQueries.ts | 37 ++++++++++ src/lib/api/endpoints/Clubs.ts | 13 ++-- src/services/clubMemberService.ts | 35 ++++++++++ src/services/clubService.ts | 18 +++-- src/types/groups/clubAdminEdit.ts | 48 +++++++++++++ src/types/groups/clubMembers.ts | 69 +++++++++++++++++++ 9 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 src/hooks/mutations/useClubAdminEditMutations.ts create mode 100644 src/hooks/mutations/useClubMemberMutations.ts create mode 100644 src/hooks/queries/useClubAdminEditQueries.ts create mode 100644 src/hooks/queries/useClubMemberQueries.ts create mode 100644 src/services/clubMemberService.ts create mode 100644 src/types/groups/clubAdminEdit.ts create mode 100644 src/types/groups/clubMembers.ts diff --git a/src/hooks/mutations/useClubAdminEditMutations.ts b/src/hooks/mutations/useClubAdminEditMutations.ts new file mode 100644 index 0000000..3884425 --- /dev/null +++ b/src/hooks/mutations/useClubAdminEditMutations.ts @@ -0,0 +1,24 @@ +// src/hooks/mutations/useClubAdminEditMutations.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { clubAdminEditQueryKeys } from "@/hooks/queries/useClubAdminEditQueries"; + +import type { UpdateClubAdminRequest } from "@/types/groups/clubAdminEdit"; + +// ✅ clubService가 객체 export인 경우 +import { clubService } from "@/services/clubService"; +// ✅ 함수 export면 이렇게 바꿔: +// import { updateAdminClub } from "@/services/clubService"; + +export function useUpdateClubAdminMutation(clubId: number) { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (body: UpdateClubAdminRequest) => + clubService.updateAdminClub(clubId, body), + // mutationFn: (body: UpdateClubAdminRequest) => updateAdminClub(clubId, body), + + onSuccess: () => { + qc.invalidateQueries({ queryKey: clubAdminEditQueryKeys.detail(clubId) }); + }, + }); +} \ No newline at end of file diff --git a/src/hooks/mutations/useClubMemberMutations.ts b/src/hooks/mutations/useClubMemberMutations.ts new file mode 100644 index 0000000..fc236a2 --- /dev/null +++ b/src/hooks/mutations/useClubMemberMutations.ts @@ -0,0 +1,29 @@ +"use client"; + +// src/hooks/mutations/useClubMemberMutations.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { clubMemberService } from "@/services/clubMemberService"; +import { clubMemberQueryKeys } from "@/hooks/queries/useClubMemberQueries"; +import type { UpdateClubMemberStatusRequest } from "@/types/groups/clubMembers"; + +type Variables = { + clubId: number; + clubMemberId: number; + body: UpdateClubMemberStatusRequest; +}; + +export function useUpdateClubMemberStatusMutation() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ clubId, clubMemberId, body }: Variables) => + clubMemberService.updateClubMemberStatus(clubId, clubMemberId, body), + + onSuccess: async (_data, variables) => { + // 가입 신청 관리 페이지는 PENDING만 보면 되니까 이거 하나만 갱신해도 충분 + await qc.invalidateQueries({ + queryKey: clubMemberQueryKeys.members(variables.clubId, "PENDING"), + }); + }, + }); +} \ No newline at end of file diff --git a/src/hooks/queries/useClubAdminEditQueries.ts b/src/hooks/queries/useClubAdminEditQueries.ts new file mode 100644 index 0000000..a3d23f0 --- /dev/null +++ b/src/hooks/queries/useClubAdminEditQueries.ts @@ -0,0 +1,17 @@ +// src/hooks/queries/useClubAdminEditQueries.ts +import { useQuery } from "@tanstack/react-query"; +import { clubService } from "@/services/clubService"; + +export const clubAdminEditQueryKeys = { + detail: (clubId: number) => ["clubAdminEdit", "detail", clubId] as const, +}; + +export function useClubAdminDetailQuery(clubId: number) { + return useQuery({ + queryKey: clubAdminEditQueryKeys.detail(clubId), + queryFn: () => clubService.getAdminClubDetail(clubId), + // queryFn: () => getAdminClubDetail(clubId), + enabled: Number.isFinite(clubId) && clubId > 0, + retry: false, + }); +} \ No newline at end of file diff --git a/src/hooks/queries/useClubMemberQueries.ts b/src/hooks/queries/useClubMemberQueries.ts new file mode 100644 index 0000000..186204c --- /dev/null +++ b/src/hooks/queries/useClubMemberQueries.ts @@ -0,0 +1,37 @@ +"use client"; + +// src/hooks/queries/useClubMemberQueries.ts +import { useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; +import { clubMemberService } from "@/services/clubMemberService"; +import type { ClubMemberListStatusFilter, GetClubMembersResult } from "@/types/groups/clubMembers"; + +export const clubMemberQueryKeys = { + members: (clubId: number, status: ClubMemberListStatusFilter) => + ["clubs", clubId, "members", status] as const, +}; + +export function useInfiniteClubMembersQuery( + clubId: number, + status: ClubMemberListStatusFilter, + enabled: boolean +) { + return useInfiniteQuery< + GetClubMembersResult, + Error, + InfiniteData, + ReturnType, + number | undefined + >({ + queryKey: clubMemberQueryKeys.members(clubId, status), + enabled, + initialPageParam: undefined, + queryFn: ({ pageParam }) => + clubMemberService.getClubMembers({ + clubId, + status, + cursorId: pageParam ?? null, + }), + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor ?? undefined : undefined, + }); +} \ No newline at end of file diff --git a/src/lib/api/endpoints/Clubs.ts b/src/lib/api/endpoints/Clubs.ts index a6a06f6..3560c0e 100644 --- a/src/lib/api/endpoints/Clubs.ts +++ b/src/lib/api/endpoints/Clubs.ts @@ -8,19 +8,14 @@ export const CLUBS = { search: `${API_BASE_URL}/clubs/search`, join: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/join`, - // 나의 상태 조회: GET /api/clubs/{clubId}/me me: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/me`, - - // 모임 홈: GET /api/clubs/{clubId}/home home: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/home`, - - // 최신 공지 1개: GET /api/clubs/{clubId}/notices/latest latestNotice: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/notices/latest`, - - // 이번 모임 바로가기: GET /api/clubs/{clubId}/meetings/next nextMeeting: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/meetings/next`, members: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/members`, // GET - member: (clubId: number, clubMemberId: number) => - `${API_BASE_URL}/clubs/${clubId}/members/${clubMemberId}`, // PATCH + member: (clubId: number, clubMemberId: number) => `${API_BASE_URL}/clubs/${clubId}/members/${clubMemberId}`, // PATCH + + detail: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}`, + update: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}`, } as const; \ No newline at end of file diff --git a/src/services/clubMemberService.ts b/src/services/clubMemberService.ts new file mode 100644 index 0000000..a2032e9 --- /dev/null +++ b/src/services/clubMemberService.ts @@ -0,0 +1,35 @@ +// src/services/clubMemberService.ts +import { apiClient } from "@/lib/api/client"; +import { CLUBS } from "@/lib/api/endpoints/Clubs"; +import type { ApiResponse } from "@/lib/api/types"; +import type { + GetClubMembersParams, + GetClubMembersResult, + UpdateClubMemberStatusRequest, +} from "@/types/groups/clubMembers"; + +export const clubMemberService = { + // GET /api/clubs/{clubId}/members?status=...&cursorId=... + getClubMembers: async (params: GetClubMembersParams): Promise => { + const { clubId, status, cursorId } = params; + + const res = await apiClient.get>(CLUBS.members(clubId), { + params: { + status, + cursorId: cursorId ?? null, + }, + }); + + return res.result; + }, + + // PATCH /api/clubs/{clubId}/members/{clubMemberId} + updateClubMemberStatus: async ( + clubId: number, + clubMemberId: number, + body: UpdateClubMemberStatusRequest + ): Promise => { + const res = await apiClient.patch>(CLUBS.member(clubId, clubMemberId), body); + return res.result; + }, +}; \ No newline at end of file diff --git a/src/services/clubService.ts b/src/services/clubService.ts index 5fee1ce..90b519a 100644 --- a/src/services/clubService.ts +++ b/src/services/clubService.ts @@ -2,9 +2,10 @@ import { apiClient } from "@/lib/api/client"; import { CLUBS } from "@/lib/api/endpoints/Clubs"; import type { ApiResponse } from "@/lib/api/types"; +import { ClubAdminDetailResponse, UpdateClubAdminRequest, UpdateClubAdminResponse } from "@/types/groups/clubAdminEdit"; import { CreateClubRequest } from "@/types/groups/clubCreate"; import { ClubJoinRequest, ClubJoinResponse, ClubSearchParams, ClubSearchResponse, MyClubsResponse, RecommendationsResponse } from "@/types/groups/clubsearch"; -import { ClubHomeResponse, ClubHomeResponseResult, LatestNoticeResponse, LatestNoticeResponseResult, MyClubStatusResponse, MyClubStatusResponseResult, NextMeetingResponse, NextMeetingResponseResult } from "@/types/groups/grouphome"; +import { ClubHomeResponse, ClubHomeResponseResult, LatestNoticeResponse, MyClubStatusResponse, MyClubStatusResponseResult, NextMeetingResponse } from "@/types/groups/grouphome"; export const clubService = { @@ -55,13 +56,11 @@ export const clubService = { return res.result; }, - // GET /api/clubs/{clubId}/me getMyStatus: async (clubId: number): Promise => { const res = await apiClient.get(CLUBS.me(clubId)); return res.result; }, - // GET /api/clubs/{clubId}/home getClubHome: async (clubId: number): Promise => { const res = await apiClient.get(CLUBS.home(clubId)); return res.result; @@ -69,7 +68,7 @@ export const clubService = { getLatestNotice: async (clubId: number) => { try { - const res = await apiClient.get(CLUBS_ENDPOINTS.latestNotice(clubId)); + const res = await apiClient.get(CLUBS.latestNotice(clubId)); return res.result; } catch (e: any) { const msg = e?.message ?? ""; @@ -84,7 +83,7 @@ export const clubService = { getNextMeeting: async (clubId: number) => { try { - const res = await apiClient.get(CLUBS_ENDPOINTS.nextMeeting(clubId)); + const res = await apiClient.get(CLUBS.nextMeeting(clubId)); return res.result; } catch (e: any) { const msg = e?.message ?? ""; @@ -97,5 +96,14 @@ getNextMeeting: async (clubId: number) => { throw e; } }, +getAdminClubDetail: async (clubId: number) => { + const res = await apiClient.get(CLUBS.detail(clubId)); + return res.result; +}, +updateAdminClub: async (clubId: number, body: UpdateClubAdminRequest) => { + const res = await apiClient.put(CLUBS.update(clubId), body); + return res.result; +}, + }; diff --git a/src/types/groups/clubAdminEdit.ts b/src/types/groups/clubAdminEdit.ts new file mode 100644 index 0000000..3994a82 --- /dev/null +++ b/src/types/groups/clubAdminEdit.ts @@ -0,0 +1,48 @@ +// src/types/groups/clubAdminEdit.ts + +import { ApiResponse } from "@/lib/api/types"; + + +export type CodeDescItem = { + code: string; + description: string; +}; + +export type ClubLinkItem = { + link: string; + label: string; +}; + +/** + * [운영진] 독서 모임 상세 조회 GET /api/clubs/{clubId} + */ +export type ClubAdminDetail = { + clubId: number; + name: string; + description: string; + profileImageUrl: string; + region: string; + category: CodeDescItem[]; // [{code, description}] + participantTypes: CodeDescItem[]; // [{code, description}] + links: ClubLinkItem[]; // [{link, label}] + open: boolean; +}; + +export type ClubAdminDetailResponse = ApiResponse; + +/** + * [운영진] 독서 모임 정보 수정 PUT /api/clubs/{clubId} + */ +export type UpdateClubAdminRequest = { + name: string; + description: string; + profileImageUrl: string; + open: boolean; + region: string; + category: string[]; + participantTypes: string[]; + links: ClubLinkItem[]; +}; + + +export type UpdateClubAdminResponse = ApiResponse; \ No newline at end of file diff --git a/src/types/groups/clubMembers.ts b/src/types/groups/clubMembers.ts new file mode 100644 index 0000000..45ee4e9 --- /dev/null +++ b/src/types/groups/clubMembers.ts @@ -0,0 +1,69 @@ +// src/types/groups/clubMembers.ts + +export type ClubMemberStatus = + | "MEMBER" + | "STAFF" + | "OWNER" + | "PENDING" + | "WITHDRAWN" + | "KICKED"; + +export type ClubMemberListStatusFilter = "ALL" | "ACTIVE" | ClubMemberStatus; + +/** + * command: 수행할 명령어 + * - APPROVE: 가입 승인 (PENDING -> MEMBER) + * - REJECT: 가입 거절 (PENDING 삭제) + * - CHANGE_ROLE: 회원/운영진 역할 변경 (MEMBER <-> STAFF) + * - TRANSFER_OWNER: 모임 소유권 이전 (actor: OWNER -> STAFF, target: MEMBER/STAFF -> OWNER) + * - KICK: 강제 탈퇴 (MEMBER/STAFF -> KICKED) + */ +export type ClubMemberCommand = + | "APPROVE" + | "REJECT" + | "CHANGE_ROLE" + | "TRANSFER_OWNER" + | "KICK"; + +export type ClubMemberDetailInfo = { + nickname: string; + profileImageUrl: string | null; + name: string; + email: string; +}; + +export type ClubMemberItem = { + clubMemberId: number; + detailInfo: ClubMemberDetailInfo; + joinMessage: string | null; + clubMemberStatus: ClubMemberStatus; + appliedAt: string; // ISO string + joinedAt: string | null; // ISO string | null +}; + +export type GetClubMembersParams = { + clubId: number; + status: ClubMemberListStatusFilter; // 여기선 ALL만 쓰는 걸 추천 + cursorId?: number | null; +}; + +export type GetClubMembersResult = { + clubMembers: ClubMemberItem[]; + hasNext: boolean; + nextCursor: number | null; +}; + +/** + * PATCH /api/clubs/{clubId}/members/{clubMemberId} + * status: CHANGE_ROLE일 때만 필요, 그 외 command에서는 무시됨 + * + * ✅ 실수 방지용으로 유니온 타입으로 강제함: + * - CHANGE_ROLE일 때만 status를 보낼 수 있음 + * - 나머지는 status를 못 붙임(붙이면 TS가 에러) + */ +export type UpdateClubMemberStatusRequest = + | { command: "APPROVE" } + | { command: "REJECT" } + | { command: "KICK" } + | { command: "TRANSFER_OWNER" } + | { command: "CHANGE_ROLE"; status: "MEMBER" | "STAFF" }; \ No newline at end of file From 21468625b5b97065ba0676cb02fd1f527b5dc0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 27 Feb 2026 05:47:07 +0900 Subject: [PATCH 05/36] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EC=97=90=20=EB=94=B0=EB=A5=B8=20UI=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/[id]/admin/applicant/page.tsx | 441 ++++++++----- src/app/groups/[id]/admin/edit/layout.tsx | 5 + src/app/groups/[id]/admin/edit/page.tsx | 620 ++++++++++++++++++ src/app/groups/[id]/admin/members/page.tsx | 562 ++++++++++------ .../base-ui/Group/group_admin_menu.tsx | 25 +- 5 files changed, 1275 insertions(+), 378 deletions(-) create mode 100644 src/app/groups/[id]/admin/edit/layout.tsx create mode 100644 src/app/groups/[id]/admin/edit/page.tsx diff --git a/src/app/groups/[id]/admin/applicant/page.tsx b/src/app/groups/[id]/admin/applicant/page.tsx index 56f1089..8dcca59 100644 --- a/src/app/groups/[id]/admin/applicant/page.tsx +++ b/src/app/groups/[id]/admin/applicant/page.tsx @@ -1,23 +1,13 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Image from 'next/image'; import { useHeaderTitle } from '@/contexts/HeaderTitleContext'; -// 더미 데이터 -const DUMMY_APPLICANTS = [ - { id: 1, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '저 가입시켜주세요. 저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.' }, - { id: 2, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '가입하고 싶습니다!' }, - { id: 3, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '책 모임에 관심이 많습니다.' }, - { id: 4, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '안녕하세요, 가입 신청합니다.' }, - { id: 5, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '열심히 활동하겠습니다!' }, - { id: 6, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '좋은 모임이라 들었습니다.' }, - { id: 7, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '독서를 좋아합니다.' }, - { id: 8, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '가입 부탁드립니다.' }, - { id: 9, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '함께하고 싶습니다.' }, - { id: 10, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '잘 부탁드립니다!' }, -]; +import { useInfiniteClubMembersQuery } from '@/hooks/queries/useClubMemberQueries'; +import { useUpdateClubMemberStatusMutation } from '@/hooks/mutations/useClubMemberMutations'; +import type { ClubMemberItem } from '@/types/groups/clubMembers'; type ActionType = 'delete' | 'approve'; @@ -27,7 +17,11 @@ type ApplicantActionDropdownProps = { buttonRef: React.RefObject; }; -function ApplicantActionDropdown({ isOpen, onSelectAction, buttonRef }: ApplicantActionDropdownProps) { +function ApplicantActionDropdown({ + isOpen, + onSelectAction, + buttonRef, +}: ApplicantActionDropdownProps) { const [position, setPosition] = useState({ top: 0, left: 0 }); useEffect(() => { @@ -92,22 +86,69 @@ function JoinMessageModal({ isOpen, onClose, message }: JoinMessageModalProps) { ); } +function formatYYYYMMDD(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '0000.00.00'; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}.${m}.${day}`; +} + export default function AdminApplicantPage() { const params = useParams(); const router = useRouter(); - const groupId = params.id as string; + const groupId = params.id as string; // clubId + const clubId = Number(groupId); + const { setCustomTitle } = useHeaderTitle(); - const [applicants, setApplicants] = useState(DUMMY_APPLICANTS); + + // dropdown / modal const [openMenuId, setOpenMenuId] = useState(null); const [messageModal, setMessageModal] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '', }); + const menuRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); const buttonRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({}); + + // pagination UI는 유지하되, 데이터는 cursor 기반으로 누적 로드해서 slice로 보여줌 const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; - const totalPages = Math.ceil(applicants.length / itemsPerPage); + + // ✅ PENDING만 조회 (cursorId 무한 스크롤) + const membersQuery = useInfiniteClubMembersQuery( + clubId, + 'PENDING', + Number.isFinite(clubId) && clubId > 0 + ); + + const { mutateAsync: updateStatus, isPending: isUpdating } = + useUpdateClubMemberStatusMutation(); + + // 서버 데이터 -> 기존 UI shape에 맞춰 가공 + const applicants = useMemo(() => { + const pages = membersQuery.data?.pages ?? []; + const raw: ClubMemberItem[] = pages.flatMap((p) => p.clubMembers ?? []); + + return raw.map((m) => ({ + id: m.clubMemberId, + userId: m.detailInfo.nickname, // 기존 UI에 보이던 ID 자리에 nickname + name: m.detailInfo.name, + email: m.detailInfo.email, + applyDate: formatYYYYMMDD(m.appliedAt), + message: m.joinMessage ?? '', + profileImageUrl: m.detailInfo.profileImageUrl ?? null, + nickname: m.detailInfo.nickname, + })); + }, [membersQuery.data]); + + // 페이지네이션 계산 + const totalPages = Math.max(1, Math.ceil(applicants.length / itemsPerPage)); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentApplicants = applicants.slice(startIndex, endIndex); // 모바일 헤더 타이틀 설정 useEffect(() => { @@ -115,33 +156,72 @@ export default function AdminApplicantPage() { return () => setCustomTitle(null); }, [setCustomTitle]); - // 바깥 클릭 시 메뉴 닫기 + // 바깥 클릭 시 메뉴 닫기 (기존 로직 유지) useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (openMenuId === null) return; const menuRef = menuRefs.current[openMenuId]; - if (menuRef && !menuRef.contains(e.target as Node)) { - setOpenMenuId(null); - } + const buttonRef = buttonRefs.current[openMenuId]; + const target = e.target as Node; + + if (menuRef && menuRef.contains(target)) return; + if (buttonRef && buttonRef.contains(target)) return; + + setOpenMenuId(null); }; + document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [openMenuId]); + // ✅ “페이지 넘겼는데 데이터가 모자라면” 다음 cursor 페이지 자동 로드 + useEffect(() => { + if (!membersQuery.hasNextPage) return; + if (membersQuery.isFetchingNextPage) return; + + const needCount = currentPage * itemsPerPage; + if (applicants.length < needCount) { + membersQuery.fetchNextPage(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage]); + + // 데이터 줄어들어서 현재 페이지가 totalPages를 넘는 상황 방지 + useEffect(() => { + if (currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages]); + const handleActionClick = (applicantId: number) => { setOpenMenuId(openMenuId === applicantId ? null : applicantId); }; - const handleSelectAction = (applicantId: number, action: ActionType) => { - if (action === 'delete') { - // 신청 삭제 - setApplicants(applicants.filter((a) => a.id !== applicantId)); - } else if (action === 'approve') { - // 가입 처리 - console.log('가입 처리:', applicantId); - setApplicants(applicants.filter((a) => a.id !== applicantId)); + const handleSelectAction = async (clubMemberId: number, action: ActionType) => { + // action 매핑: + // delete -> REJECT (PENDING 삭제) + // approve -> APPROVE (PENDING -> MEMBER) + try { + if (action === 'delete') { + await updateStatus({ + clubId, + clubMemberId, + body: { + command: 'REJECT', + status: 'MEMBER' + }, + }); + } else { + await updateStatus({ + clubId, + clubMemberId, + body: { + command: 'APPROVE', + status: 'MEMBER' + }, + }); + } + } finally { + setOpenMenuId(null); } - setOpenMenuId(null); }; const handleMessageClick = (message: string) => { @@ -152,13 +232,12 @@ export default function AdminApplicantPage() { setMessageModal({ isOpen: false, message: '' }); }; - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const currentApplicants = applicants.slice(startIndex, endIndex); + const goProfile = (nickname: string) => { + router.push(`/profile/${nickname}`); + }; return (
-
-
-

- 운영진은 가입 처리를 통해 회원의 가입 유무를 선택 가능합니다.
이때 공개모임의 경우 모든 신청마다 즉시 가입완료 처리 됩니다. -

-
+
+

+ 운영진은 가입 처리를 통해 회원의 가입 유무를 선택 가능합니다.
이때 공개모임의 경우 + 모든 신청마다 즉시 가입완료 처리 됩니다. +

+
- {/* 테이블 */} -
-
- {/* 테이블 헤더 */} -
- {/* ID - 태블릿/데스크탑에서만 */} -
-

ID

-
-
-

이름

-
- {/* 이메일 - 태블릿/데스크탑에서만 */} -
-

이메일

-
-
-

신청 일자

-
-
-

프로필

-

프로필 보기

-
-
-

가입메시지

-
-
-

수정

+ {/* 로딩/에러 표시(기존 UI는 유지하면서 최소만 추가) */} + {membersQuery.isLoading && ( +

불러오는 중…

+ )} + {membersQuery.isError && ( +

목록을 불러오지 못했습니다.

+ )} + + {/* 테이블 */} +
+
+ {/* 테이블 헤더 */} +
+
+

ID

+
+
+

이름

+
+
+

이메일

+
+
+

신청 일자

+
+
+

프로필

+

프로필 보기

+
+
+

가입메시지

+
+
+

수정

+
-
- {/* 테이블 바디 */} -
- {currentApplicants.map((applicant) => ( -
- {/* ID - 태블릿/데스크탑에서만 */} -
-
- {applicant.name} + {/* 테이블 바디 */} +
+ {currentApplicants.map((applicant) => ( +
+
+
+ {applicant.name} +
+

{applicant.userId}

-

{applicant.userId}

-
-
-

{applicant.name}

-
- {/* 이메일 - 태블릿/데스크탑에서만 */} -
-

{applicant.email}

-
-
-

{applicant.applyDate}

-
-
-

바로가기

-
-
- +
+ +
+ +
+ +
{ + menuRefs.current[applicant.id] = el; + }} > - 가입메시지 - + + + handleSelectAction(applicant.id, action)} + buttonRef={{ current: buttonRefs.current[applicant.id] }} + /> +
-
{ menuRefs.current[applicant.id] = el; }}> - - handleSelectAction(applicant.id, action)} - buttonRef={{ current: buttonRefs.current[applicant.id] }} - /> + ))} + + {!membersQuery.isLoading && applicants.length === 0 && !membersQuery.isError && ( +
+

가입 대기(PENDING) 멤버가 없습니다.

-
- ))} + )} +
-
- {/* 페이지네이션 */} -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + {/* 페이지네이션 (UI 그대로 유지) */} +
- ))} - -
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + + +
+ + {/* “더 가져올 게 있는데 아직 페이지 수가 부족한” 케이스 안내 (선택) */} + {membersQuery.hasNextPage && ( +
+ +
+ )}
@@ -329,4 +442,4 @@ export default function AdminApplicantPage() { />
); -} +} \ No newline at end of file diff --git a/src/app/groups/[id]/admin/edit/layout.tsx b/src/app/groups/[id]/admin/edit/layout.tsx new file mode 100644 index 0000000..c5efa93 --- /dev/null +++ b/src/app/groups/[id]/admin/edit/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function GroupAdminEditLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/groups/[id]/admin/edit/page.tsx b/src/app/groups/[id]/admin/edit/page.tsx new file mode 100644 index 0000000..04bcc48 --- /dev/null +++ b/src/app/groups/[id]/admin/edit/page.tsx @@ -0,0 +1,620 @@ +"use client"; + +import React, { useMemo, useRef, useState, useEffect } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import toast from "react-hot-toast"; + +import Chip from "@/components/base-ui/Group-Create/Chip"; +import { + BOOK_CATEGORIES, + BookCategory, + PARTICIPANT_LABEL_TO_TYPE, + ParticipantLabel, + PARTICIPANTS, + ParticipantType, +} from "@/types/groups/groups"; + +import { mapBookCategoriesToCodes } from "@/types/groups/clubCreate"; + +// ✅ create에서 쓰던 업로드 훅 그대로 재사용 +import { useUploadClubImageMutation } from "@/hooks/mutations/useCreateClubMutation"; + +// ✅ 너가 방금 만들라고 한 edit용 hooks +import { useClubAdminDetailQuery } from "@/hooks/queries/useClubAdminEditQueries"; +import { useUpdateClubAdminMutation } from "@/hooks/mutations/useClubAdminEditMutations"; + +type NameCheckState = "idle" | "checking" | "available" | "duplicate"; +type SnsLink = { label: string; url: string }; + +function cx(...classes: (string | false | null | undefined)[]) { + return classes.filter(Boolean).join(" "); +} + +const autoResize = (el: HTMLTextAreaElement) => { + el.style.height = "0px"; + const H = el.scrollHeight + 5; + el.style.height = `${H}px`; +}; + +export default function EditClubPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const clubId = Number(params.id); + + // ===== API ===== + const { data, isLoading } = useClubAdminDetailQuery(clubId); + const uploadImage = useUploadClubImageMutation(); + const updateClub = useUpdateClubAdminMutation(clubId); + + // ===== create랑 동일한 state 구조 유지 ===== + // Step 1 + const [clubName, setClubName] = useState(""); + const [clubDescription, setClubDescription] = useState(""); + const [nameCheck, setNameCheck] = useState("idle"); + + // edit 전용: 원래 이름 기억해서 "이름 안 바꾸면 자동 통과" 처리 + const [initialName, setInitialName] = useState(""); + + const DuplicationCheckisConfirmed = nameCheck === "available"; + const DuplicationCheckisDisabled = + !clubName.trim() || nameCheck === "checking" || DuplicationCheckisConfirmed; + + // Step 2 + const [profileMode, setProfileMode] = useState<"default" | "upload">("default"); + const [selectedImageUrl, setSelectedImageUrl] = useState(null); + const [profileImageUrl, setProfileImageUrl] = useState(null); + + // ✅ 서버에 그대로 보내는 값: open(boolean) + const [open, setOpen] = useState(null); + + const fileRef = useRef(null); + + // Step 3 + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedParticipants, setSelectedParticipants] = useState([]); + const [activityArea, setActivityArea] = useState(""); + + // Step 4 + const [links, setLinks] = useState([{ label: "", url: "" }]); + + // ===== GET 상세로 초기값 세팅 (create UI 흐름에 맞춰 mapping) ===== + useEffect(() => { + if (!data) return; + + setClubName(data.name ?? ""); + setInitialName(data.name ?? ""); + setClubDescription(data.description ?? ""); + + // ✅ edit에서 “이름 안 바꿨으면” 중복확인 없이 통과 상태 + setNameCheck("available"); + + // 프로필: 기존 값이 있으면 업로드 모드처럼 보여주기(미리보기) + const img = data.profileImageUrl ?? ""; + if (img) { + setProfileMode("upload"); + setSelectedImageUrl(img); + setProfileImageUrl(img); + } else { + setProfileMode("default"); + setSelectedImageUrl(null); + setProfileImageUrl(null); + } + + setOpen(Boolean(data.open)); + setActivityArea(data.region ?? ""); + + // category / participantTypes는 서버 코드 배열 -> create의 라벨 배열로 변환 + // ✅ 네 프로젝트에 이미 매핑 상수(BOOK_CATEGORY_TO_CODE 같은거) 있다면 그걸 쓰는게 정답. + // 여기선 groups.ts의 BOOK_CATEGORIES 라벨을 그대로 쓰기 위해 "description"을 라벨로 쓰는 방식으로 맞춤. + const categoryLabels = (data.category ?? []) + .map((c) => c.description as BookCategory) + .filter(Boolean); + setSelectedCategories(categoryLabels); + + const participantLabels = (data.participantTypes ?? []) + .map((p) => p.description as ParticipantLabel) + .filter(Boolean); + setSelectedParticipants(participantLabels); + + setLinks( + (data.links ?? []).length + ? (data.links ?? []).map((l) => ({ label: l.label ?? "", url: l.link ?? "" })) + : [{ label: "", url: "" }] + ); + }, [data]); + + // ===== name 중복 확인 (edit에서는 이름 바뀐 경우만) ===== + // ⚠️ create에선 useClubNameCheckQuery를 썼는데, edit 페이지에선 “UI 완전 동일” 원칙 때문에 버튼/문구 동일 유지. + // 다만 실제 체크 API 훅이 edit쪽에 없다면, create 훅을 그대로 import해서 쓰면 됨. + // 여기서는 "이름이 바뀌면 체크 필요"만 UI로 강제한다. + const onCheckName = async () => { + const name = clubName.trim(); + if (!name) return; + + // ✅ 이름이 원래랑 같으면 그냥 통과 + if (name === initialName) { + setNameCheck("available"); + toast.success("사용 가능한 모임 이름입니다."); + return; + } + + // 여기서 실제 중복체크 훅이 있다면 create 페이지처럼 refetch하면 됨. + // 지금은 “UI 동일”이 목표라, 최소 동작만(나중에 hook 연결) 형태로 둠. + setNameCheck("checking"); + try { + // TODO: useClubNameCheckQuery(name).refetch() 연결 + // 임시: 무조건 available 처리 + setNameCheck("available"); + toast.success("사용 가능한 모임 이름입니다."); + } catch { + setNameCheck("idle"); + toast.error("이름 중복 확인 실패"); + } + }; + + // 이미지 선택: create와 동일 (미리보기 + 업로드) + const pickImage = async (file: File) => { + const reader = new FileReader(); + reader.onloadend = () => setSelectedImageUrl(reader.result as string); + reader.readAsDataURL(file); + + try { + const imageUrl = await uploadImage.mutateAsync(file); + setProfileImageUrl(imageUrl); + toast.success("프로필 이미지 업로드 완료"); + } catch { + setProfileImageUrl(null); + toast.error("이미지 업로드 실패"); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const toggleWithLimit = (arr: T[], item: T, limit: number) => { + if (arr.includes(item)) return arr.filter((x) => x !== item); + if (arr.length >= limit) return arr; + return [...arr, item]; + }; + + const updateLink = (idx: number, patch: Partial) => { + setLinks((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it))); + }; + + const addLinkRow = () => setLinks((prev) => [...prev, { label: "", url: "" }]); + + const removeLinkRow = (idx: number) => { + setLinks((prev) => prev.filter((_, i) => i !== idx)); + }; + + // ✅ create의 canNext 조건을 “전체 저장 가능 조건”으로 그대로 유지 + const canSave = useMemo(() => { + // Step1 조건 + const ok1 = Boolean(clubName.trim() && clubDescription.trim() && nameCheck === "available"); + if (!ok1) return false; + + // Step2 조건 + if (open === null) return false; + if (profileMode === "upload") { + if (!profileImageUrl) return false; + if (uploadImage.isPending) return false; + } + + // Step3 조건 + const ok3 = + selectedCategories.length > 0 && + selectedParticipants.length > 0 && + activityArea.trim().length > 0; + if (!ok3) return false; + + // Step4는 선택이라 항상 true + return true; + }, [ + clubName, + clubDescription, + nameCheck, + open, + profileMode, + profileImageUrl, + uploadImage.isPending, + selectedCategories, + selectedParticipants, + activityArea, + ]); + + const onSubmitUpdate = async () => { + if (open === null) { + toast.error("공개/비공개를 선택해주세요."); + return; + } + + try { + const category = mapBookCategoriesToCodes(selectedCategories); + const participantTypes: ParticipantType[] = selectedParticipants.map( + (label) => PARTICIPANT_LABEL_TO_TYPE[label] + ); + + const linksPayload = links + .map((l) => ({ label: l.label.trim(), link: l.url.trim() })) + .filter((l) => l.label && l.link); + + const payload = { + name: clubName.trim(), + description: clubDescription.trim(), + profileImageUrl: profileMode === "upload" ? profileImageUrl : null, + region: activityArea.trim(), + category, + participantTypes, + links: linksPayload, + open: open === true, + }; + + await updateClub.mutateAsync(payload as any); + toast.success("모임 정보가 수정되었습니다."); + router.back(); + } catch { + toast.error("모임 수정 실패"); + } + }; + + return ( +
+ {/* breadcrumb: create랑 동일한 스타일 유지 (문구만 수정) */} +
+ +

모임

+ + + + + +

모임 수정

+
+ +
+
+ {/* ====== STEP 1 UI 그대로 (조건부 제거) ====== */} +
+

독서모임 이름

+ +
+ { + setClubName(e.target.value); + // ✅ edit: 이름이 바뀌면 다시 체크 요구 + if (e.target.value.trim() === initialName) setNameCheck("available"); + else setNameCheck("idle"); + }} + placeholder="독서 모임 이름을 입력해주세요." + className="w-full h-[44px] t:h-[56px] rounded-[8px] border border-[#EAE5E2] p-4 outline-none bg-white body_1_3 t:subhead_4_1" + /> + + +
+ +
+ 다른 이름을 입력하거나, 기수 또는 지역명을 추가해 구분해주세요. +
+ 예) 독서재량 2기, 독서재량 서울, 북적북적 인문학팀 +
+ +
+ {nameCheck === "available" && ( +

사용 가능한 모임 이름입니다.

+ )} + {nameCheck === "duplicate" && ( +

이미 존재하는 모임 이름입니다.

+ )} +
+ +

모임의 소개글

+