From 230314956c5fb65e5addd94d8d99e25e8a38b3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 15:27:34 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat=20:=20APIResponse=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/lib/api/types.ts diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..b835249 --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,7 @@ +// ~/types/auth.ts에 정의되어 있는건 아는데 거기 있는 거보단 여기가 맞을 거 같아서 빼두겠습니다. +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; \ No newline at end of file From 833509837a47b16d55234ed4f1d52c5cab082bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 16:50:47 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A3=BC=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api/endpoints/Clubs.ts | 6 ++++++ src/lib/api/endpoints/Image.ts | 8 ++++++++ src/lib/api/endpoints/index.ts | 2 ++ 3 files changed, 16 insertions(+) create mode 100644 src/lib/api/endpoints/Clubs.ts create mode 100644 src/lib/api/endpoints/Image.ts diff --git a/src/lib/api/endpoints/Clubs.ts b/src/lib/api/endpoints/Clubs.ts new file mode 100644 index 0000000..14c393e --- /dev/null +++ b/src/lib/api/endpoints/Clubs.ts @@ -0,0 +1,6 @@ +import { API_BASE_URL } from "../endpoints"; + +export const CLUBS = { + create: `${API_BASE_URL}/clubs`, // POST /api/clubs + checkName: `${API_BASE_URL}/clubs/check-name`, // GET /api/clubs/check-name?clubName= +} as const; \ No newline at end of file diff --git a/src/lib/api/endpoints/Image.ts b/src/lib/api/endpoints/Image.ts new file mode 100644 index 0000000..9237435 --- /dev/null +++ b/src/lib/api/endpoints/Image.ts @@ -0,0 +1,8 @@ +import { API_BASE_URL } from "../endpoints"; + +export type ImageUploadType = "PROFILE" | "CLUB" | "NOTICE"; + +export const IMAGE = { + uploadUrl: (type: ImageUploadType) => + `${API_BASE_URL}/image/${type}/upload-url`, // POST +} as const; \ No newline at end of file diff --git a/src/lib/api/endpoints/index.ts b/src/lib/api/endpoints/index.ts index 463a8a2..cdbbf5f 100644 --- a/src/lib/api/endpoints/index.ts +++ b/src/lib/api/endpoints/index.ts @@ -3,3 +3,5 @@ export * from "./auth"; export * from "./bookstory"; export * from "./member"; export * from "./book"; +export * from "./Clubs"; +export * from "./Image"; From 2e0b84fe1865b3ad7a617f175dc4a25ddfc52373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 16:51:11 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore=20:=20=EB=AA=A8=EC=9E=84=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20type=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/groups/clubCreate.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/types/groups/clubCreate.ts diff --git a/src/types/groups/clubCreate.ts b/src/types/groups/clubCreate.ts new file mode 100644 index 0000000..70324e3 --- /dev/null +++ b/src/types/groups/clubCreate.ts @@ -0,0 +1,61 @@ +// src/types/groups/clubCreate.ts +import type { BookCategory, ParticipantType } from "@/types/groups/groups"; + + +export type ClubLink = { + link: string; + label: string; +}; + +export type CreateClubRequest = { + name: string; + description: string; + profileImageUrl: string | null; // 없으면 null로 보내는 게 깔끔 + region: string; + category: ClubCategoryCode[]; + participantTypes: ParticipantType[]; + links: ClubLink[]; + open: boolean; +}; + + +// src/types/groups/clubCreate.ts + +export type ClubCategoryCode = + | "FICTION_POETRY_DRAMA" + | "ESSAY" + | "HUMANITIES" + | "SOCIAL_SCIENCE" + | "POLITICS_DIPLOMACY_DEFENSE" + | "ECONOMY_MANAGEMENT" + | "SELF_DEVELOPMENT" + | "HISTORY_CULTURE" + | "SCIENCE" + | "COMPUTER_IT" + | "ART_POP_CULTURE" + | "TRAVEL" + | "FOREIGN_LANGUAGE" + | "CHILDREN_BOOKS" + | "RELIGION_PHILOSOPHY"; + +export const BOOK_CATEGORY_TO_CODE: Record = { + "여행": "TRAVEL", + "외국어": "FOREIGN_LANGUAGE", + "어린이/청소년": "CHILDREN_BOOKS", + "종교/철학": "RELIGION_PHILOSOPHY", + "소설/시/희곡": "FICTION_POETRY_DRAMA", + "에세이": "ESSAY", + "인문학": "HUMANITIES", + "사회과학": "SOCIAL_SCIENCE", + "정치/외교/국방": "POLITICS_DIPLOMACY_DEFENSE", + "경제/경영": "ECONOMY_MANAGEMENT", + "자기계발": "SELF_DEVELOPMENT", + "역사/문화": "HISTORY_CULTURE", + "과학": "SCIENCE", + "컴퓨터/IT": "COMPUTER_IT", + "예술/대중문화": "ART_POP_CULTURE", +}; + +export function mapBookCategoriesToCodes(categories: BookCategory[]) { + return categories.map((c) => BOOK_CATEGORY_TO_CODE[c]); +} \ No newline at end of file From d2f45cbc9dac54613b7329c86f160f7d300a8303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 16:51:49 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/create/page.tsx | 993 ++++++++++--------- src/hooks/mutations/useCreateClubMutation.ts | 19 + src/hooks/queries/useCreateClubQueries.ts | 17 + src/services/clubService.ts | 19 + src/services/imageService.ts | 48 + 5 files changed, 622 insertions(+), 474 deletions(-) create mode 100644 src/hooks/mutations/useCreateClubMutation.ts create mode 100644 src/hooks/queries/useCreateClubQueries.ts create mode 100644 src/services/clubService.ts create mode 100644 src/services/imageService.ts diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx index 5c14412..8effe14 100644 --- a/src/app/groups/create/page.tsx +++ b/src/app/groups/create/page.tsx @@ -1,13 +1,29 @@ -'use client' +"use client"; import React, { useMemo, useRef, useState } from "react"; -import Image from 'next/image'; +import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; + import StepDot from "@/components/base-ui/Group-Create/StepDot"; 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 { + BOOK_CATEGORIES, + BookCategory, + PARTICIPANT_LABEL_TO_TYPE, + ParticipantLabel, + PARTICIPANTS, + ParticipantType, +} from "@/types/groups/groups"; + +import { mapBookCategoriesToCodes } from "@/types/groups/clubCreate"; +import { useClubNameCheckQuery } from "@/hooks/queries/useCreateClubQueries"; +import { + useCreateClubMutation, + useUploadClubImageMutation, +} from "@/hooks/mutations/useCreateClubMutation"; type NameCheckState = "idle" | "checking" | "available" | "duplicate"; - type SnsLink = { label: string; url: string }; function cx(...classes: (string | false | null | undefined)[]) { @@ -15,26 +31,32 @@ function cx(...classes: (string | false | null | undefined)[]) { } const autoResize = (el: HTMLTextAreaElement) => { - el.style.height = "0px"; // shrink 먼저 + el.style.height = "0px"; const H = el.scrollHeight + 5; - el.style.height = `${H}px`; // 내용만큼 늘리기 + el.style.height = `${H}px`; }; export default function CreateClubWizardPreview() { + const router = useRouter(); + const [step, setStep] = useState(1); // Step 1 const [clubName, setClubName] = useState(""); const [clubDescription, setClubDescription] = useState(""); const [nameCheck, setNameCheck] = useState("idle"); + const DuplicationCheckisConfirmed = nameCheck === "available"; - const DuplicationCheckisDisabled = !clubName.trim() || nameCheck === "checking" || DuplicationCheckisConfirmed ; + 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); const [visibility, setVisibility] = useState<"공개" | "비공개" | null>(null); const fileRef = useRef(null); + // Step 3 const [selectedCategories, setSelectedCategories] = useState([]); const [selectedParticipants, setSelectedParticipants] = useState([]); @@ -43,38 +65,90 @@ export default function CreateClubWizardPreview() { // Step 4 const [links, setLinks] = useState([{ label: "", url: "" }]); + // API hooks + const nameQuery = useClubNameCheckQuery(clubName); // enabled:false라 버튼에서 refetch로만 호출됨 + const uploadImage = useUploadClubImageMutation(); + const createClub = useCreateClubMutation(); + const canNext = useMemo(() => { if (step === 1) return Boolean(clubName.trim() && clubDescription.trim() && nameCheck === "available"); - if (step === 2) return Boolean(visibility); + + if (step === 2) { + // 공개/비공개는 필수 + if (!visibility) return false; + // 업로드 모드면 업로드 완료(=profileImageUrl 확보)까지 기다리게 + if (profileMode === "upload") return Boolean(profileImageUrl) && !uploadImage.isPending; + return true; + } + if (step === 3) return selectedCategories.length > 0 && selectedParticipants.length > 0 && activityArea.trim(); - if (step === 4) return true; // 선택 + if (step === 4) return true; return false; - }, [step, clubName, clubDescription,nameCheck ,visibility, selectedCategories, selectedParticipants, activityArea]); + }, [ + step, + clubName, + clubDescription, + nameCheck, + visibility, + profileMode, + profileImageUrl, + uploadImage.isPending, + selectedCategories, + selectedParticipants, + activityArea, + ]); const onPrev = () => setStep((v) => Math.max(1, v - 1)); const onNext = () => setStep((v) => Math.min(4, v + 1)); - const fakeCheckName = () => { - if (!clubName.trim()) return; + // 2) 모임 이름 중복 확인 + const onCheckName = async () => { + const name = clubName.trim(); + if (!name) return; + setNameCheck("checking"); - setTimeout(() => { - // 그냥 프리뷰용: 이름에 "중복" 들어가면 duplicate 처리 - if (clubName.includes("중복")) setNameCheck("duplicate"); - else setNameCheck("available"); - }, 500); + + try { + const r = await nameQuery.refetch(); + const isDuplicate = r.data; // boolean + + if (isDuplicate) { + setNameCheck("duplicate"); + toast.error("이미 존재하는 모임 이름입니다."); + } else { + setNameCheck("available"); + toast.success("사용 가능한 모임 이름입니다."); + } + } catch (e) { + setNameCheck("idle"); + toast.error("이름 중복 확인 실패"); + } }; - const pickImage = (file: File) => { + // 이미지 선택: 미리보기 + presigned 업로드 + imageUrl 저장 + 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 (e) { + + setProfileImageUrl(null); + toast.error("이미지 업로드 실패"); + } }; 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]; -}; + 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))); @@ -88,407 +162,405 @@ export default function CreateClubWizardPreview() { setLinks((prev) => prev.filter((_, i) => i !== idx)); }; + // 모임 생성 (최종) + const onSubmitCreateClub = async () => { + 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: visibility === "공개", + }; + + + const msg = await createClub.mutateAsync(payload); + + toast.success(typeof msg === "string" ? msg : msg?.result ?? "성공"); + router.push("/groups"); + } catch (e) { + toast.error("모임 생성 실패"); + } + }; + return (
- - - {/* breadcrumb */} -
- -

모임

- - - - - - -

새 모임 생성

-
- - -
- {/* step dots */} -
-
- - - - -
+ {/* breadcrumb */} +
+ +

모임

+ + + + +

새 모임 생성

+
+ +
+ {/* step dots */} +
+
+ + + +
+
- {/* 본문 박스 */}
- {/* STEP 1 */} {step === 1 && ( -
-

- 독서 모임 이름을 입력해주세요! -

- -
- { - setClubName(e.target.value); - 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" && ( -

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

- )} -
- -

- 모임의 소개글을 입력해주세요! -

-