diff --git a/next.config.ts b/next.config.ts index baf92c4..3aca7b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,6 +12,8 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "checkmo-s3-presigned.s3.ap-northeast-2.amazonaws.com", + pathname: "/**", + }, ], }, diff --git a/src/app/globals.css b/src/app/globals.css index 086e1a3..0fa9b48 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -175,3 +175,11 @@ body { @utility animate-slide-down { animation: slide-down 0.3s ease-out; } + +@utility no-scrollbar { + -ms-overflow-style: none; /* IE/Edge */ + scrollbar-width: none; /* Firefox */ +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} \ No newline at end of file diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx index 5c14412..c2810d9 100644 --- a/src/app/groups/create/page.tsx +++ b/src/app/groups/create/page.tsx @@ -1,13 +1,30 @@ -'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 +32,35 @@ 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 [visibility, setVisibility] = useState<"공개" | "비공개" | null>(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([]); @@ -43,452 +69,533 @@ 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 === 3) return selectedCategories.length > 0 && selectedParticipants.length > 0 && activityArea.trim(); - if (step === 4) return true; // 선택 + if (step === 1) + return Boolean(clubName.trim() && clubDescription.trim() && nameCheck === "available"); + + if (step === 2) { + // ✅ 공개/비공개는 필수 + if (open === null) 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().length > 0; + + if (step === 4) return true; + return false; - }, [step, clubName, clubDescription,nameCheck ,visibility, selectedCategories, selectedParticipants, activityArea]); + }, [ + step, + clubName, + clubDescription, + nameCheck, + open, + 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 { + setNameCheck("idle"); + toast.error("이름 중복 확인 실패"); + } }; - const pickImage = (file: File) => { + // 이미지 선택: 미리보기 + 업로드 + 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 { + setProfileImageUrl(null); + toast.error("이미지 업로드 실패"); + + // ✅ 같은 파일 다시 선택 가능하게 input 초기화 + 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]; -}; + 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 addLinkRow = () => setLinks((prev) => [...prev, { label: "", url: "" }]); const removeLinkRow = (idx: number) => { setLinks((prev) => prev.filter((_, i) => i !== idx)); }; - return ( -
- - - {/* breadcrumb */} -
- -

모임

- - - - - - -

새 모임 생성

-
- + // 모임 생성 (최종) + const onSubmitCreateClub = async () => { + // 안전장치: Step2 open 미선택이면 막기 + 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, // ✅ boolean 확정 + }; + + // ✅ 규칙대로면 service에서 res.result(string)만 반환해야 함 + const msg = await createClub.mutateAsync(payload); + toast.success(msg.result); + + router.push("/groups"); + } catch { + toast.error("모임 생성 실패"); + } + }; -
- {/* step dots */} -
-
- - - - -
+ return ( +
+ {/* 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" && ( -

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

- )} -
- -

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

-