diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73ab239 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# API Endpoints +NEXT_PUBLIC_API_URL=https://api.checkmo.co.kr/api diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/next.config.ts b/next.config.ts index 66e1566..b427f9f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,19 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "image.aladin.co.kr", + }, + { + protocol: "https", + hostname: "checkmo-s3-presigned.s3.ap-northeast-2.amazonaws.com", + pathname: "/**", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 346fd42..1e77e57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "checkmo", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", @@ -288,6 +290,45 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", diff --git a/package.json b/package.json index 13073fa..eeddb52 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", @@ -16,6 +21,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-hot-toast": "^2.6.0", + "react-intersection-observer": "^10.0.3", "zod": "^4.3.6", "zustand": "^5.0.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9732f85..e3166a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,21 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.0) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.0) + '@tanstack/react-query-devtools': + specifier: ^5.91.3 + version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.0))(react@19.2.0) '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@16.1.6(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) @@ -29,6 +44,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-intersection-observer: + specifier: ^10.0.3 + version: 10.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) zod: specifier: ^4.3.6 version: 4.3.6 @@ -152,6 +170,28 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.7.0': resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} @@ -651,6 +691,23 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/query-devtools@5.93.0': + resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} + + '@tanstack/react-query-devtools@5.91.3': + resolution: {integrity: sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==} + peerDependencies: + '@tanstack/react-query': ^5.90.20 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1891,6 +1948,15 @@ packages: react: '>=16' react-dom: '>=16' + react-intersection-observer@10.0.3: + resolution: {integrity: sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2297,6 +2363,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + '@emnapi/core@1.7.0': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -2682,6 +2773,21 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@tanstack/query-core@5.90.20': {} + + '@tanstack/query-devtools@5.93.0': {} + + '@tanstack/react-query-devtools@5.91.3(@tanstack/react-query@5.90.21(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/query-devtools': 5.93.0 + '@tanstack/react-query': 5.90.21(react@19.2.0) + react: 19.2.0 + + '@tanstack/react-query@5.90.21(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.0 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -4019,6 +4125,12 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + react-intersection-observer@10.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + optionalDependencies: + react-dom: 19.2.0(react@19.2.0) + react-is@16.13.1: {} react@19.2.0: {} diff --git a/public/ArrowLeft3.svg b/public/ArrowLeft3.svg new file mode 100644 index 0000000..2ff3385 --- /dev/null +++ b/public/ArrowLeft3.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/Polygon7.svg b/public/Polygon7.svg new file mode 100644 index 0000000..270d420 --- /dev/null +++ b/public/Polygon7.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icon_plus_2.svg b/public/icon_plus_2.svg new file mode 100644 index 0000000..28e26cd --- /dev/null +++ b/public/icon_plus_2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/(main)/books/[id]/page.tsx b/src/app/(main)/books/[id]/page.tsx index f2b6472..71de064 100644 --- a/src/app/(main)/books/[id]/page.tsx +++ b/src/app/(main)/books/[id]/page.tsx @@ -1,84 +1,95 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useParams, useRouter } from "next/navigation"; import SearchBookResult from "@/components/base-ui/Search/search_bookresult"; import { DUMMY_STORIES } from "@/data/dummyStories"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; +import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; export default function BookDetailPage() { - const params = useParams(); - const router = useRouter(); - const bookId = Number(params.id); - const [liked, setLiked] = useState(false); + const params = useParams(); + const router = useRouter(); + const isbn = params.id as string; + const [liked, setLiked] = useState(false); - // 더미 데이터 - const bookData = { - id: 1, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }; + const { data: bookData, isLoading, isError } = useBookDetailQuery(isbn); - // 관련된 책 이야기들 (더미 데이터에서 필터링) - const relatedStories = DUMMY_STORIES.filter( - (story) => story.bookTitle === bookData.title, - ); + // 관련된 책 이야기들 (더미 데이터에서 필터링) + const relatedStories = useMemo(() => { + if (!bookData) return []; + return DUMMY_STORIES.filter((story) => story.bookTitle === bookData.title); + }, [bookData]); - return ( -
-
-

- 도서 선택 {bookData.title} 중 -

+ if (isLoading) { + return ( +
+

도서 정보를 불러오는 중...

+
+ ); + } - {/* 선택한 책 카드 */} -
- { - router.push(`/stories/new?bookId=${bookId}`); - }} - /> -
+ if (isError || !bookData) { + return ( +
+

도서 정보를 찾을 수 없습니다.

+
+ ); + } - {/* 책이야기 */} -
-

- 책이야기{" "} - {relatedStories.length} -

-
+ return ( +
+
+

+ 도서 선택 {bookData.title} 중 +

+ + {/* 선택한 책 카드 */} +
+ { + router.push(`/stories/new?isbn=${isbn}`); + }} + /> +
+ + {/* 책이야기 */} +
+

+ 책이야기{" "} + {relatedStories.length} +

+
- {/* 책 이야기 카드 */} -
- {relatedStories.map((story) => ( -
router.push(`/stories/${story.id}`)} - className="cursor-pointer" - > - + {/* 책 이야기 카드 */} +
+ {relatedStories.map((story) => ( +
router.push(`/stories/${story.id}`)} + className="cursor-pointer" + > + +
+ ))} +
- ))}
-
-
- ); + ); } diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index bdafd34..5d0fc5b 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Image from "next/image"; import BookStoryCard from "@/components/base-ui/BookStory/bookstory_card"; import NewsBannerSlider from "@/components/base-ui/home/NewsBannerSlider"; @@ -8,22 +8,31 @@ import HomeBookclub from "@/components/base-ui/home/home_bookclub"; import ListSubscribeLarge from "@/components/base-ui/home/list_subscribe_large"; import ListSubscribeElement from "@/components/base-ui/home/list_subscribe_element"; import LoginModal from "@/components/base-ui/Login/LoginModal"; -import { DUMMY_STORIES } from "@/data/dummyStories"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; import { useAuthStore } from "@/store/useAuthStore"; +import { useStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; export default function HomePage() { const groups: { id: string; name: string }[] = []; const { isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore(); - // 사용자 더미 데이터 - const users = [ - { id: "1", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - { id: "2", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - { id: "3", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - { id: "4", name: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - ]; + const { data: storiesData, isLoading: isLoadingStories } = useStoriesQuery(); + const { data: membersData, isLoading: isLoadingMembers } = useRecommendedMembersQuery(isLoggedIn); + + const stories = storiesData?.basicInfoList || []; + const recommendedUsers = membersData?.friends || []; + const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers); + + if (isLoading) { + return ( +
+
+
+ ); + } + return (
{isLoginModalOpen && ( @@ -50,13 +59,12 @@ export default function HomePage() { 사용자 추천
- {users.slice(0, 3).map((u) => ( + {recommendedUsers.slice(0, 3).map((u, idx) => ( console.log("subscribe", u.id)} + key={u.nickname + idx} + name={u.nickname} + profileSrc={u.profileImageUrl} + onSubscribeClick={() => console.log("subscribe", u.nickname)} /> ))}
@@ -67,16 +75,17 @@ export default function HomePage() { {/* 책 이야기 카드 */}
- {DUMMY_STORIES.slice(0, 3).map((story) => ( + {stories.slice(0, 3).map((story) => ( ))} @@ -101,23 +110,24 @@ export default function HomePage() {
- +
{/* 책 이야기 카드 */}
- {DUMMY_STORIES.slice(0, 4).map((story) => ( + {stories.slice(0, 4).map((story) => ( ))} @@ -134,7 +144,7 @@ export default function HomePage() {
- +
@@ -151,16 +161,17 @@ export default function HomePage() { {/* 책 이야기 카드 */}
- {DUMMY_STORIES.slice(0, 3).map((story) => ( + {stories.slice(0, 3).map((story) => ( ))} diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index d899d30..f0be4d1 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -1,16 +1,18 @@ "use client"; -import { useState, useEffect, Suspense } from "react"; +import { useState, useEffect, Suspense, useMemo } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Image from "next/image"; import SearchBookResult from "@/components/base-ui/Search/search_bookresult"; +import { useInfiniteBookSearchQuery } from "@/hooks/queries/useBookQueries"; +import { useInView } from "react-intersection-observer"; function SearchContent() { const searchParams = useSearchParams(); const router = useRouter(); const query = searchParams.get("q") || ""; const [searchValue, setSearchValue] = useState(query); - const [likedResults, setLikedResults] = useState>({}); + const [likedResults, setLikedResults] = useState>({}); useEffect(() => { setSearchValue(query); @@ -28,30 +30,31 @@ function SearchContent() { } }; - // 더미 검색 결과 데이터 - const searchResults = [ - { - id: 1, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - { - id: 2, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - { - id: 3, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - ]; + const { + data: searchData, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteBookSearchQuery(query); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const searchResults = useMemo(() => { + return searchData?.pages.flatMap((page) => page.detailInfoList) || []; + }, [searchData]); + + const totalResults = useMemo(() => { + // Note: If the API doesn't return total count, we can only show what's loaded + // or just say "many" or keep it as is. Assuming we show loaded count for now. + return searchResults.length; + }, [searchResults]); return ( <> @@ -100,29 +103,36 @@ function SearchContent() {

- 총 {searchResults.length}개의 검색결과가 있습니다. + 총 {totalResults}개의 검색결과가 있습니다.

{searchResults.map((result) => ( - setLikedResults((prev) => ({ ...prev, [result.id]: liked })) + setLikedResults((prev) => ({ ...prev, [result.isbn]: liked })) } onPencilClick={() => { - router.push(`/books/${result.id}`); //필요한지 확인 필요 + router.push(`/books/${result.isbn}`); //필요한지 확인 필요 }} onCardClick={() => { - router.push(`/books/${result.id}`); + router.push(`/books/${result.isbn}`); }} /> ))}
+ + {/* 무한 스크롤 로딩 트리거 */} +
+ {isFetchingNextPage && ( +
+ )} +
diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index a3eaa88..b3ebf4b 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -1,27 +1,39 @@ +"use client"; + import BookstoryDetail from "@/components/base-ui/BookStory/bookstory_detail"; import StoryNavigation from "@/components/base-ui/BookStory/story_navigation"; import CommentSection from "@/components/base-ui/Comment/comment_section"; import Image from "next/image"; -import { getStoryById, getAdjacentStoryIds } from "@/data/dummyStories"; -import { notFound } from "next/navigation"; - -type Props = { - params: Promise<{ id: string }>; -}; +import { isValidUrl } from "@/utils/url"; +import { useParams } from "next/navigation"; +import { useStoryDetailQuery } from "@/hooks/queries/useStoryQueries"; -export default async function StoryDetailPage({ params }: Props) { - const { id } = await params; +export default function StoryDetailPage() { + const params = useParams(); + const id = params?.id as string; + const { data: story, isLoading, isError } = useStoryDetailQuery(Number(id)); - // id로 스토리 데이터 가져오기 - const story = getStoryById(Number(id)); + if (isLoading) { + return ( +
+
+
+ ); + } - // 스토리가 없으면 404 - if (!story) { - notFound(); + // 스토리가 없으면 404 UI + if (!story || isError) { + return ( +
+

404

+

해당 책 이야기를 찾을 수 없습니다.

+
+ ); } - // 이전/다음 스토리 ID - const { prevId, nextId } = getAdjacentStoryIds(Number(id)); + const prevId = story.prevBookStoryId !== 0 ? story.prevBookStoryId : null; + const nextId = story.nextBookStoryId !== 0 ? story.nextBookStoryId : null; + return (
@@ -56,30 +68,35 @@ export default async function StoryDetailPage({ params }: Props) {
{/* 메인 콘텐츠 영역 */}
- + {/* 책이야기 글 본문 */}
-

{story.title}

+

{story.bookStoryTitle}

- {story.content} + {story.description}

{/* 댓글 */}
- +
diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index 5d09d79..75689b9 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -3,41 +3,67 @@ import { useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; +import { toast } from "react-hot-toast"; import BookstoryText from "@/components/base-ui/BookStory/bookstory_text"; import BookstoryChoosebook from "@/components/base-ui/BookStory/bookstory_choosebook"; import BookSelectModal from "@/components/layout/BookSelectModal"; +import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; +import { useCreateBookStoryMutation } from "@/hooks/mutations/useStoryMutations"; function StoryNewContent() { const router = useRouter(); const searchParams = useSearchParams(); - const bookId = searchParams.get("bookId"); + const isbn = searchParams.get("isbn"); const [title, setTitle] = useState(""); const [detail, setDetail] = useState(""); const [isBookSelectModalOpen, setIsBookSelectModalOpen] = useState(false); - // 더미 데이터 - const selectedBook = bookId - ? { - id: Number(bookId), - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - } - : null; + const { data: selectedBook } = useBookDetailQuery(isbn || ""); + const createStoryMutation = useCreateBookStoryMutation(); const handleCancel = () => { router.back(); }; const handleSubmit = () => { - // TODO: 실제 저장 로직 구현 - console.log("저장:", { title, detail }); - router.push("/stories"); + if (!selectedBook) { + toast.error("책을 선택해 주세요."); + return; + } + if (!title.trim()) { + toast.error("제목을 입력해 주세요."); + return; + } + if (!detail.trim()) { + toast.error("내용을 입력해 주세요."); + return; + } + + createStoryMutation.mutate({ + bookInfo: { + isbn: selectedBook.isbn, + title: selectedBook.title, + author: selectedBook.author, + imgUrl: selectedBook.imgUrl, + publisher: selectedBook.publisher, + description: selectedBook.description, + }, + title, + description: detail, + }, { + onSuccess: () => { + toast.success("스토리가 등록되었습니다!"); + router.push("/stories"); + }, + onError: (error) => { + console.error("스토리 등록 실패:", error); + toast.error("스토리 등록에 실패했습니다. 다시 시도해 주세요."); + } + }); }; - const handleBookSelect = (selectedBookId: number) => { - router.push(`/stories/new?bookId=${selectedBookId}`); + const handleBookSelect = (selectedIsbn: string) => { + router.push(`/stories/new?isbn=${selectedIsbn}`); }; return ( @@ -84,7 +110,7 @@ function StoryNewContent() { bookUrl={selectedBook.imgUrl} bookName={selectedBook.title} author={selectedBook.author} - bookDetail={selectedBook.detail} + bookDetail={selectedBook.description} onButtonClick={() => { setIsBookSelectModalOpen(true); }} @@ -125,9 +151,10 @@ function StoryNewContent() {
diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index fa6ae07..1612dd0 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -1,20 +1,54 @@ "use client"; +import { useEffect, useState } from "react"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; import ListSubscribeLarge from "@/components/base-ui/home/list_subscribe_large"; import { useRouter } from "next/navigation"; -import { DUMMY_STORIES } from "@/data/dummyStories"; import FloatingFab from "@/components/base-ui/Float"; - -// TODO: 실제 로그인 상태 여부는 나중에 -const isLoggedIn = false; // true: 로그인, false: 로그인X +import { useAuthStore } from "@/store/useAuthStore"; +import { useInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; +import { useInView } from "react-intersection-observer"; export default function StoriesPage() { const router = useRouter(); + const { isLoggedIn } = useAuthStore(); + const { + data: storiesData, + isLoading: isLoadingStories, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteStoriesQuery(); + + const { data: membersData, isLoading: isLoadingMembers } = useRecommendedMembersQuery(isLoggedIn); + + const { ref, inView } = useInView({ + threshold: 0, + }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const handleCardClick = (id: number) => { router.push(`/stories/${id}`); }; + const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers); + + if (isLoading) { + return ( +
+
+
+ ); + } + + const allStories = storiesData?.pages.flatMap((page) => page.basicInfoList) || []; + const recommendedMembers = membersData?.friends || []; + return (
@@ -35,53 +69,73 @@ export default function StoriesPage() { {/* 메인 콘텐츠 영역 */}
- {/* 첫 번째 줄 */} - {DUMMY_STORIES.slice(0, 4).map((story) => ( + {/* 첫 번째 줄 (처음 4개) */} + {allStories.slice(0, 4).map((story) => (
handleCardClick(story.id)} + key={story.bookStoryId} + onClick={() => handleCardClick(story.bookStoryId)} className="cursor-pointer shrink-0" >
))} - {/* 두 번째 줄: 비로그인 시 사용자 추천 + 카드 3개, 로그인 시 카드 4개 */} - {!isLoggedIn && } - {DUMMY_STORIES.slice(4, isLoggedIn ? 8 : 7).map((story) => ( + {/* 두 번째 줄: 추천 멤버가 있을 경우에만 추천 영역 표시 */} + {recommendedMembers.length > 0 && ( + + )} + + {/* 나머지 카드들 */} + {allStories.slice(4).map((story) => (
handleCardClick(story.id)} + key={story.bookStoryId} + onClick={() => handleCardClick(story.bookStoryId)} className="cursor-pointer shrink-0" >
))}
+ + {/* 무한 스크롤 옵저버 타겟 */} + {hasNextPage && ( +
+ {isFetchingNextPage ? ( +
+ ) : ( +
+ )} +
+ )} {/* 글쓰기 버튼 */} router.push("/stories/new")} - /> + iconSrc="/icons_pencil.svg" + iconAlt="글쓰기" + onClick={() => router.push("/stories/new")} + />
); diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts b/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts new file mode 100644 index 0000000..d8ac0b9 --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts @@ -0,0 +1,48 @@ +import { GetMeetingTeamsResult } from "@/types/groups/bookcasedetail"; + + +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; + +export const MEETING_TEAMS_DUMMY: ApiResponse = { + isSuccess: true, + code: "COMMON200", + message: "성공입니다.", + result: { + existingTeamNumbers: [1, 2], + members: [ + { + clubMemberId: 9, + memberInfo: { nickname: "문학러버", profileImageUrl: null }, + teamNumber: null, + }, + { + clubMemberId: 8, + memberInfo: { nickname: "독서광5", profileImageUrl: null }, + teamNumber: null, + }, + { + clubMemberId: 2, + memberInfo: { nickname: "테스터2", profileImageUrl: null }, + teamNumber: null, + }, + { + clubMemberId: 1, + memberInfo: { nickname: "테스터1", profileImageUrl: null }, + teamNumber: 1, + }, + ], + hasNext: false, + nextCursor: null, + }, +}; + +// 더미 fetch처럼 쓰려고 약간의 딜레이를 줌. +export async function fetchMeetingTeamsDummy() { + await new Promise((r) => setTimeout(r, 150)); + return MEETING_TEAMS_DUMMY; +} diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx new file mode 100644 index 0000000..3463306 --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function meetingeditLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx new file mode 100644 index 0000000..5f0cf3e --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { + DndContext, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, +} from "@dnd-kit/core"; + + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; + + +import { fetchMeetingTeamsDummy } from "./dummy"; +import { normalizeTeams, TeamMember, TeamMemberListPutBody } from "@/types/groups/bookcasedetail"; +import MemberPool from "@/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool"; +import TeamBoard from "@/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard"; +import Image from "next/image"; +export default function AdminMeetingTeamManagePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + + // Next가 params를 Record로 주는 케이스가 있어서 안전빵 + const groupId = Array.isArray(params?.id) ? params?.id[0] : (params?.id as string | undefined); + const meetingId = Array.isArray(params?.meetingId) + ? params?.meetingId[0] + : (params?.meetingId as string | undefined); + + const meetingName = + searchParams.get("meetingName") || searchParams.get("name") || "정기모임 이름"; + + const [isLoading, setIsLoading] = useState(true); + const [teams, setTeams] = useState([1]); + const [members, setMembers] = useState([]); + + // 드래그 하이라이트용 + const [dragOverTeamNumber, setDragOverTeamNumber] = useState(null); + const [isDragOverPool, setIsDragOverPool] = useState(false); + + useEffect(() => { + let alive = true; + + (async () => { + setIsLoading(true); + + // TODO(API 연동): 여기서 GET /groups/{groupId}/meetings/{meetingId}/teams 같은 걸 호출해서 + // existingTeamNumbers + members를 받아오면 됨. + const res = await fetchMeetingTeamsDummy(); + + if (!alive) return; + + const normalized = normalizeTeams(res.result.existingTeamNumbers); + setTeams(normalized); + setMembers(res.result.members); + setIsLoading(false); + })(); + + return () => { + alive = false; + }; + }, []); + + const unassigned = useMemo( + () => members.filter((m) => m.teamNumber == null), + [members] + ); + + const handleAddTeam = () => { + setTeams((prev) => { + if (prev.length >= 7) return prev; + return [...prev, prev.length + 1]; + }); + }; + + const handleRemoveTeam = (teamNumber: number) => { + if (teams.length <= 1) return; + + // C(3) 삭제 -> A(1)B(2)C(3) 로 당기고, 기존 C에 있던 애들은 null로 빠지게. + setTeams((prev) => { + const filtered = prev.filter((t) => t !== teamNumber); + return filtered.map((t) => (t > teamNumber ? t - 1 : t)); + }); + + setMembers((prev) => + prev.map((m) => { + if (m.teamNumber === teamNumber) return { ...m, teamNumber: null }; + if (m.teamNumber != null && m.teamNumber > teamNumber) + return { ...m, teamNumber: m.teamNumber - 1 }; + return m; + }) + ); + }; + + const handleMoveMember = (clubMemberId: number, toTeamNumber: number | null) => { + setMembers((prev) => + prev.map((m) => (m.clubMemberId === clubMemberId ? { ...m, teamNumber: toTeamNumber } : m)) + ); + }; + + const handleSubmit = async () => { + // PUT payload 만들기 + const body: TeamMemberListPutBody = { + teamMemberList: teams.map((teamNumber) => ({ + teamNumber, + clubMemberIds: members + .filter((m) => m.teamNumber === teamNumber) + .map((m) => m.clubMemberId), + })), + }; + + // TODO(API 연동): + // await fetch(`/api/groups/${groupId}/admin/bookcase/${meetingId}`, { method: 'PUT', body: JSON.stringify(body) }) + console.log("PUT payload:", body); + }; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + // 모바일에서 스크롤/탭과 드래그 충돌 줄이려고 “롱프레스” 약간 줌 + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 8 }, + }) +); + +const handleDndOver = ({ over }: DragOverEvent) => { + const id = over?.id?.toString(); + if (!id) { + setDragOverTeamNumber(null); + setIsDragOverPool(false); + return; + } + + if (id === "pool") { + setIsDragOverPool(true); + setDragOverTeamNumber(null); + return; + } + + if (id.startsWith("team-")) { + const teamNumber = Number(id.replace("team-", "")); + setDragOverTeamNumber(Number.isFinite(teamNumber) ? teamNumber : null); + setIsDragOverPool(false); + } +}; + +const handleDndEnd = ({ active, over }: DragEndEvent) => { + try { + if (!over) return; + + const clubMemberId = active.data.current?.clubMemberId as number | undefined; + + if (clubMemberId === undefined) { + return; + } + + const overId = over.id.toString(); + + if (overId === "pool") { + handleMoveMember(clubMemberId, null); + } else if (overId.startsWith("team-")) { + const toTeamNumber = Number(overId.replace("team-", "")); + if (Number.isFinite(toTeamNumber)) handleMoveMember(clubMemberId, toTeamNumber); + } + } finally { + // 하이라이트 정리 + setDragOverTeamNumber(null); + setIsDragOverPool(false); + } +}; + + + return ( + +
+ {/* 모바일 전용 뒤로가기 바 */} + + + {/* t 이상에서: 좌우 최소 40px 확보용 외곽 패딩 */} +
+ {/* 실제 컨텐츠 래퍼 */} +
+ {/* 타이틀 */} +

{meetingName}

+ + +
+ {/* 미배정 참여자 */} +
+ +
+ + {/* 팀 영역: 모바일 아래 / t 이상 왼쪽 */} +
+ {isLoading ? ( +
+ 불러오는 중... +
+ ) : ( + + )} +
+
+
+
+
+ +
+ +); +} diff --git a/src/app/groups/[id]/admin/bookcase/new/page.tsx b/src/app/groups/[id]/admin/bookcase/new/page.tsx index 1a3ecd5..a27157f 100644 --- a/src/app/groups/[id]/admin/bookcase/new/page.tsx +++ b/src/app/groups/[id]/admin/bookcase/new/page.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import Image from 'next/image'; import BookSelectModal from '@/components/layout/BookSelectModal'; import BookstoryChoosebook from '@/components/base-ui/BookStory/bookstory_choosebook'; +import { useBookDetailQuery } from '@/hooks/queries/useBookQueries'; import { useHeaderTitle } from '@/contexts/HeaderTitleContext'; const TAGS = [ @@ -36,7 +37,7 @@ export default function NewBookshelfPage() { const router = useRouter(); const searchParams = useSearchParams(); const groupId = params.id as string; - const bookId = searchParams.get('bookId'); + const isbn = searchParams.get('isbn'); const { setCustomTitle } = useHeaderTitle(); // 모바일 헤더 타이틀 설정 @@ -45,16 +46,7 @@ export default function NewBookshelfPage() { return () => setCustomTitle(null); }, [setCustomTitle]); - // 더미 데이터 - const selectedBook = bookId - ? { - id: Number(bookId), - imgUrl: '/booksample.svg', - title: '어린 왕자', - author: '김개미, 연수', - detail: '최대 500(넘어가면...으로)', - } - : null; + const { data: selectedBook } = useBookDetailQuery(isbn || ''); const [generation, setGeneration] = useState('1'); const [isGenerationOpen, setIsGenerationOpen] = useState(false); @@ -93,8 +85,8 @@ export default function NewBookshelfPage() { ); }; - const handleBookSelect = (selectedBookId: number) => { - router.push(`/groups/${groupId}/admin/bookcase/new?bookId=${selectedBookId}`); + const handleBookSelect = (selectedIsbn: string) => { + router.push(`/groups/${groupId}/admin/bookcase/new?isbn=${selectedIsbn}`); }; const handleBack = () => { @@ -131,7 +123,7 @@ export default function NewBookshelfPage() { bookUrl={selectedBook.imgUrl} bookName={selectedBook.title} author={selectedBook.author} - bookDetail={selectedBook.detail} + bookDetail={selectedBook.description} onButtonClick={() => setIsBookSelectModalOpen(true)} /> ) : ( @@ -175,11 +167,10 @@ export default function NewBookshelfPage() { setGeneration(num.toString()); setIsGenerationOpen(false); }} - className={`w-22 h-8 px-3 text-left subhead_4_1 cursor-pointer ${ - generation === num.toString() - ? 'bg-Subbrown-4 text-Gray-7' - : 'bg-White text-Gray-7 hover:bg-Subbrown-4' - }`} + className={`w-22 h-8 px-3 text-left subhead_4_1 cursor-pointer ${generation === num.toString() + ? 'bg-Subbrown-4 text-Gray-7' + : 'bg-White text-Gray-7 hover:bg-Subbrown-4' + }`} > {num} @@ -200,11 +191,10 @@ export default function NewBookshelfPage() { key={index} type="button" onClick={() => handleTagToggle(index)} - className={`h-10 px-4 py-1 rounded-[8px] body_2_2 cursor-pointer transition-colors ${ - isSelected - ? `${getTagBgColor(index)} text-White` - : 'bg-transparent text-Gray-4 border border-Gray-2' - }`} + className={`h-10 px-4 py-1 rounded-[8px] body_2_2 cursor-pointer transition-colors ${isSelected + ? `${getTagBgColor(index)} text-White` + : 'bg-transparent text-Gray-4 border border-Gray-2' + }`} > {label} diff --git a/src/app/groups/[id]/admin/bookcase/page.tsx b/src/app/groups/[id]/admin/bookcase/page.tsx deleted file mode 100644 index 5e027ad..0000000 --- a/src/app/groups/[id]/admin/bookcase/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// src/app/groups/[id]/admin/bookcase/page.tsx - -"use client"; - -import { useRouter, useParams } from "next/navigation"; -import BookcaseCard from "@/components/base-ui/Bookcase/BookcaseCard"; - -// [1] 더미 데이터 생성 헬퍼 함수 (회원 페이지와 동일) -const createMockBooks = (generation: string, count: number) => - Array.from({ length: count }).map((_, i) => ({ - id: `${generation}-${i}`, // 실제 DB 연동 시 bookId가 들어감 - title: "채식주의자", - author: "한강 지음", - imageUrl: "/dummy_book_cover.png", - category: { - generation: generation, - genre: "소설/시/희곡", - }, - rating: 4.5, - })); - -// [2] 기수별 데이터 그룹화 -const BOOKCASE_DATA = [ - { - generation: "8기", - books: createMockBooks("8기", 4), - }, - { - generation: "7기", - books: createMockBooks("7기", 8), - }, -]; - -export default function AdminBookcaseListPage() { - const router = useRouter(); - const params = useParams(); - const groupId = params.id as string; - - // [이동 로직] 도서 상세(운영진) 페이지로 이동 - const handleGoToDetail = (bookId: string) => { - // 경로: /groups/[id]/admin/bookcase/[bookId] - router.push(`/groups/${groupId}/admin/bookcase/${bookId}`); - }; - - return ( - // [UI] 회원 페이지와 동일한 레이아웃 구조 -
- {/* 책장 리스트 영역 */} - {BOOKCASE_DATA.map((group) => ( -
- {/* 기수 라벨 */} -
- {group.generation} -
- - {/* 카드 리스트 */} - {/* 반응형 정렬: 모바일/태블릿(Center) -> 데스크탑(Start) */} -
- {group.books.map((book) => ( - handleGoToDetail(book.id)} - onReviewClick={() => handleGoToDetail(book.id)} - onMeetingClick={() => handleGoToDetail(book.id)} - /> - ))} -
-
- ))} -
- ); -} diff --git a/src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx b/src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx new file mode 100644 index 0000000..ed23300 --- /dev/null +++ b/src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import MeetingInfo from "@/components/base-ui/Bookcase/MeetingInfo"; +import TeamFilter from "@/components/base-ui/Bookcase/bookid/TeamFilter"; +import TeamSection from "@/components/base-ui/Bookcase/bookid/TeamSection"; + +/** ===== API 타입 (네가 준 응답 기준) ===== */ +type MeetingDetailResult = { + meetingId: number; + title: string; + meetingTime: string; // "2026-02-10T15:08:24.373372" + location: string; + existingTeamNumbers: number[]; + teams: { + teamNumber: number; + members: { + clubMemberId: number; + memberInfo: { + nickname: string; + profileImageUrl: string | null; + }; + }[]; + }[]; + /** 서버에서 추가될 예정 */ + isAdmin?: boolean; +}; + +type Props = { + meetingId: number; + onManageTeamsClick?: () => void; +}; + +/** ===== 더미 (일단 동작 검증용) ===== */ +const MOCK_MEETING_DETAIL: MeetingDetailResult = { + meetingId: 1, + title: "살인자ㅇ난감 함께 읽기", + meetingTime: "2026-02-10T15:08:24.373372", + location: "강남역 2번 출구", + existingTeamNumbers: [1], + teams: [ + { + teamNumber: 1, + members: [ + { clubMemberId: 1, memberInfo: { nickname: "테스터1", profileImageUrl: null } }, + { clubMemberId: 2, memberInfo: { nickname: "테스터2", profileImageUrl: null } }, + ], + }, + ], + isAdmin: true, +}; + +function formatDateDot(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + // fallback: 앞 10자리 "YYYY-MM-DD" + return iso.slice(0, 10).replaceAll("-", "."); + } + 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}`; +} + +function teamNumberToLabel(teamNumber: number) { + // 1 -> A조, 2 -> B조 ... (너 기존 dummy가 "A조"라서 맞춰줌) + const code = 64 + teamNumber; // 'A' = 65 + if (code >= 65 && code <= 90) return `${String.fromCharCode(code)}조`; + return `${teamNumber}조`; +} + +export default function MeetingTabSection({ meetingId, onManageTeamsClick }: Props) { + const [data, setData] = useState(null); + + + useEffect(() => { + const load = async () => { + // TODO: 여기서 API 호출로 교체 + setData({ ...MOCK_MEETING_DETAIL, meetingId }); + }; + + load(); + }, [meetingId]); + + const teamLabels = useMemo(() => { + if (!data) return []; + const fromExisting = (data.existingTeamNumbers ?? []).map(teamNumberToLabel); + const fromTeams = (data.teams ?? []).map((t) => teamNumberToLabel(t.teamNumber)); + // 중복 제거 + 순서 유지 + return Array.from(new Set([...fromExisting, ...fromTeams])); + }, [data]); + + const [selectedTeam, setSelectedTeam] = useState(""); + + // data 로딩되면 첫 팀으로 초기화 + useEffect(() => { + if (teamLabels.length === 0) return; + if (!selectedTeam || !teamLabels.includes(selectedTeam)) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectedTeam(teamLabels[0]); + } + }, [teamLabels, selectedTeam]); + + const currentTeamMembers = useMemo(() => { + if (!data) return null; + const labelToNumber = (label: string) => { + // "A조" -> 1, "B조" -> 2 ... + const ch = label?.[0]; + if (!ch) return null; + const code = ch.charCodeAt(0); + if (code >= 65 && code <= 90) return code - 64; + return null; + }; + + const teamNumber = labelToNumber(selectedTeam); + const team = data.teams.find((t) => t.teamNumber === teamNumber); + + // TeamSection이 원하는 형태로 매핑 (너 예전 코드 기준) + const members = + team?.members.map((m) => ({ + id: String(m.clubMemberId), + name: m.memberInfo.nickname, + profileImageUrl: m.memberInfo.profileImageUrl ?? "/profile4.svg", + })) ?? []; + + return { + teamName: selectedTeam, + members, + }; + }, [data, selectedTeam]); + + if (!data) { + return ( +
+
+ 모임 정보 불러오는 중... +
+
+ ); + } + + return ( +
+ {/* 2-1. 모임 정보 카드 */} + + + {/* 2-2. 조 + 멤버 (겉으로는 한 덩어리 UX) */} +
+ + + {currentTeamMembers && ( + + )} +
+
+ ); +} diff --git a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx index a93dd79..643ee95 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx @@ -3,8 +3,9 @@ import React, { useEffect, useMemo, useState } from "react"; import Image from "next/image"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import FloatingFab from "@/components/base-ui/Float"; +import ChatTeamSelectModal, { ChatTeam } from "@/components/base-ui/Bookcase/ChatTeamSelectModal" type Team = { teamId: string; @@ -19,8 +20,7 @@ type TeamDebateItem = { profileImageUrl?: string | null; }; - -const CHECKED_BG = "#F7FEF3"; +const CHECKED_BG = "#F7FEF3"; const DEFAULT_PROFILE = "/profile4.svg"; const normalizeSrc = (src?: string | null) => { @@ -40,7 +40,6 @@ const sortCheckedFirstStable = ( list: TeamDebateItem[], checkedMap: Record ) => { - // "현재 순서"를 기준으로 stable 정렬 const baseIndex = new Map(list.map((x, i) => [String(x.id), i])); const next = [...list]; @@ -48,10 +47,7 @@ const sortCheckedFirstStable = ( const ca = checkedMap[String(a.id)] ? 1 : 0; const cb = checkedMap[String(b.id)] ? 1 : 0; - // checked가 먼저 if (cb !== ca) return cb - ca; - - // 같은 그룹이면 기존 순서 유지 return (baseIndex.get(String(a.id)) ?? 0) - (baseIndex.get(String(b.id)) ?? 0); }); @@ -126,6 +122,7 @@ export default function MeetingPage({ }) { const { bookId } = params; const sp = useSearchParams(); + const router = useRouter(); const initialTeamName = sp.get("team"); // ?team=A조 const [teams, setTeams] = useState([]); @@ -134,10 +131,14 @@ export default function MeetingPage({ const [items, setItems] = useState([]); const [checkedMap, setCheckedMap] = useState>({}); + // ✅ 채팅 조 선택 모달 상태 + const [isChatTeamModalOpen, setIsChatTeamModalOpen] = useState(false); + const selectedTeam = useMemo( () => teams.find((t) => t.teamId === selectedTeamId) ?? null, [teams, selectedTeamId] ); + const selectedTeamName = selectedTeam?.teamName ?? ""; const teamNames = useMemo(() => teams.map((t) => t.teamName), [teams]); @@ -172,7 +173,6 @@ export default function MeetingPage({ return () => { ignore = true; }; - // initialTeamName으로 초기 선택 바뀔 수 있으니 포함 }, [bookId, initialTeamName]); /** 2) 팀 선택 바뀌면 발제 로드 + 체크 초기화 */ @@ -212,6 +212,23 @@ export default function MeetingPage({ setItems((prev) => sortCheckedFirstStable(prev, checkedMap)); }; + /** + * 조 선택하면 "채팅 페이지"로 이동 (입장만) + * - chat은 "현재 경로 하위의 chat"으로 이동시키는 상대 경로 + * - teamId/teamName을 쿼리로 넘겨서 다음 페이지에서 어떤 방인지 알게 함 + */ + const handleEnterChat = (team: ChatTeam) => { + setIsChatTeamModalOpen(false); + + // 상대경로: 현재 페이지가 .../meeting 이면 .../meeting/chat 로 감 + router.push( + `chat?teamId=${team.teamId}&teamName=${encodeURIComponent(team.teamName)}` + ); + + // ❗️만약 상대경로가 안 맞으면 절대경로로 바꿔야 함: + // router.push(`/groups/${params.groupId}/meeting/${bookId}/chat?teamId=${team.teamId}`); + }; + return (
@@ -270,10 +287,10 @@ export default function MeetingPage({
); } diff --git a/src/app/groups/[id]/bookcase/[bookId]/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/page.tsx index 60d6366..c713fdd 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/page.tsx @@ -1,45 +1,75 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation"; + import BookDetailCard from "@/components/base-ui/Bookcase/BookDetailCard"; -import BookDetailNav from "@/components/base-ui/Bookcase/BookDetailNav"; -import MeetingInfo from "@/components/base-ui/Bookcase/MeetingInfo"; +import BookDetailNav, { Tab as TabKey } from "@/components/base-ui/Bookcase/BookDetailNav"; import DebateSection from "./DebateSection"; -import TeamFilter from "@/components/base-ui/Bookcase/bookid/TeamFilter"; -import TeamSection from "@/components/base-ui/Bookcase/bookid/TeamSection"; import ReviewSection from "./ReviewSection"; +import MeetingTabSection from "./MeetingTabSection"; import { MOCK_BOOK_DETAIL, - MOCK_MEETING_INFO, MOCK_DEBATE_TOPICS, - MOCK_TEAMS_DATA, MOCK_REVIEWS, -} from './dummy'; +} from "./dummy"; + + +function isTabKey(v: string | null): v is TabKey { + return v === "topic" || v === "review" || v === "meeting"; +} export default function AdminBookDetailPage() { - const [activeTab, setActiveTab] = useState<"발제" | "한줄평" | "정기모임">( - "정기모임" - ); - const [MyprofileImageUrl, setMyprofileImageUrl] = useState("/profile4.svg"); - const [MyName, setMyName] = useState("aasdfsad"); + const router = useRouter(); + const pathname = usePathname(); // /groups/201/bookcase/3 + const searchParams = useSearchParams(); + const params = useParams(); - // 발제 - const [isDebateWriting, setIsDebateWriting] = useState(false); + const groupId = params.id as string; + const meetingIdParam = (params.meetingId ?? params.bookId) as string; // 폴더명 차이 커버 + const meetingId = Number(meetingIdParam); + + const [activeTab, setActiveTab] = useState("meeting"); + const [myProfileImageUrl] = useState("/profile4.svg"); + const [myName] = useState("aasdfsad"); + + const [isDebateWriting, setIsDebateWriting] = useState(false); const [isReviewWriting, setIsReviewWriting] = useState(false); - // 조 선택 상태 관리 - const [selectedTeam, setSelectedTeam] = useState("A조"); - - // 현재 선택된 조의 데이터 찾기 - const currentTeamData = MOCK_TEAMS_DATA.find( - (t) => t.teamName === selectedTeam - ); + + // URL -> state 동기화 (직접 ?tab=topic 들어와도 맞춰줌) + useEffect(() => { + const tab = searchParams.get("tab"); + if (isTabKey(tab) && tab !== activeTab) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setActiveTab(tab); + } + if (!tab) { + const next = new URLSearchParams(searchParams.toString()); + next.set("tab", "meeting"); + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + } + + }, [searchParams]); + + // state -> URL 동기화 (탭 바꾸면 ?tab=도 같이 바뀜) + const handleTabChange = (tab: TabKey) => { + setActiveTab(tab); + const next = new URLSearchParams(searchParams.toString()); + next.set("tab", tab); + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + }; + + const handleManageTeams = () => { + router.push( + `/groups/${groupId}/admin/bookcase/${meetingId}?meetingName=${encodeURIComponent(MOCK_BOOK_DETAIL.title)}` + ); + }; return (
- {/* 1. 도서 상세 카드 */} - {/* 2. 하단 상세 정보 영역 */}
- {/* 내비게이션 바 */} - + - {/* 탭 컨텐츠 영역 */}
- {activeTab === "정기모임" && ( - <> - {/* 2-1. 모임 정보 카드 */} - - - {/* 2-2. 조별 멤버 리스트 영역 (Frame 2087328794) */} -
- {/* 조 선택 필터 (Frame 2087328778) */} - t.teamName)} - selectedTeam={selectedTeam} - onSelect={setSelectedTeam} - /> - - {/* 선택된 조의 멤버 리스트 섹션 (Frame 2087328793) */} - {currentTeamData && ( - - )} -
- + {activeTab === "meeting" && ( + )} - {activeTab === '발제' && ( + {activeTab === "topic" && ( setIsDebateWriting((v) => !v)} onSendDebate={(text) => { - console.log('send:', text); - // TODO: API 붙일 곳 + console.log("topic send:", { meetingId, text }); + // TODO: topic API 연결부 return true; }} items={MOCK_DEBATE_TOPICS} /> )} - - {activeTab === '한줄평' && ( + {activeTab === "review" && ( setIsReviewWriting((v) => !v)} onSendReview={(text, rating) => { - console.log('review send:', { text, rating }); + console.log("review send:", { meetingId, text, rating }); + // TODO: review API 연결부 return true; }} items={MOCK_REVIEWS} - onClickMore={(id) => console.log('more:', id)} + onClickMore={(id) => console.log("more:", id)} /> )}
@@ -122,4 +127,4 @@ export default function AdminBookDetailPage() {
); -} +} \ No newline at end of file diff --git a/src/app/groups/[id]/bookcase/page.tsx b/src/app/groups/[id]/bookcase/page.tsx index 16fc909..d85cf86 100644 --- a/src/app/groups/[id]/bookcase/page.tsx +++ b/src/app/groups/[id]/bookcase/page.tsx @@ -1,80 +1,94 @@ "use client"; - import BookcaseCard from "@/components/base-ui/Bookcase/BookcaseCard"; import FloatingFab from "@/components/base-ui/Float"; +import { BookcaseApiResponse, groupByGeneration } from "@/types/groups/bookcasehome"; import { useParams, useRouter } from "next/navigation"; -// [1] 더미 데이터 생성 헬퍼 함수 -const createMockBooks = (generation: string, count: number) => - Array.from({ length: count }).map((_, i) => ({ - id: `${generation}-${i}`, - title: "채식주의자", - author: "한강 지음", - imageUrl: "/dummy_book_cover.png", - category: { - generation: generation, - genre: "소설/시/희곡", - }, - rating: 4.5, - })); - -// [2] 기수별 데이터 그룹화 -const BOOKCASE_DATA = [ - { - generation: "8기", - books: createMockBooks("8기", 8), - }, - { - generation: "7기", - books: createMockBooks("7기", 7), +//API 형태 그대로 +const MOCK_BOOKCASE_RESPONSE: BookcaseApiResponse = { + isSuccess: true, + code: "COMMON200", + message: "성공입니다.", + result: { + bookShelfInfoList: [ + { + meetingInfo: { meetingId: 2, generation: 1, tag: "MEETING", averageRate: 3 }, + bookInfo: { + bookId: "9791192625133", + title: "거인의 어깨 1 - 벤저민 그레이엄, 워런 버핏, 피터 린치에게 배우다", + author: "홍진채", + imgUrl: null, + }, + }, + { + meetingInfo: { meetingId: 1, generation: 1, tag: "MEETING", averageRate: 4.5 }, + bookInfo: { + bookId: "9791192005317", + title: "살인자ㅇ난감", + author: "꼬마비", + imgUrl: null, + }, + }, + { + meetingInfo: { meetingId: 3, generation: 2, tag: "MEETING", averageRate: 3.8 }, + bookInfo: { + bookId: "1", + title: "더미 책", + author: "더미 작가", + imgUrl: null, + }, + }, + ], + hasNext: false, + nextCursor: null, }, -]; +}; export default function BookcasePage() { const router = useRouter(); const params = useParams(); const groupId = params.id as string; - const handleGoToDetail = (bookId: string) => { - // 경로: /groups/[id]/admin/bookcase/[bookId] - router.push(`/groups/${groupId}/bookcase/${bookId}`); - }; + const sections = groupByGeneration(MOCK_BOOKCASE_RESPONSE.result.bookShelfInfoList); + + type TabParam = "topic" | "review" | "meeting"; + + const handleGoToDetail = (meetingId: number, tab: TabParam) => { + router.push(`/groups/${groupId}/bookcase/${meetingId}?tab=${tab}`); +}; return (
{/* 책장 리스트 영역 */} - {BOOKCASE_DATA.map((group) => ( + {sections.map((section) => (
{/* 기수 라벨 */}
- {group.generation} + {section.generationLabel}
{/* 카드 리스트 */} -
- {group.books.map((book) => ( + {section.books.map((book) => ( handleGoToDetail(book.id)} - onReviewClick={() => handleGoToDetail(book.id)} - onMeetingClick={() => handleGoToDetail(book.id)} + onTopicClick={() => handleGoToDetail(book.meetingId, "topic")} + onReviewClick={() => handleGoToDetail(book.meetingId, "review")} + onMeetingClick={() => handleGoToDetail(book.meetingId, "meeting")} /> ))}
-
))} diff --git a/src/app/groups/[id]/layout.tsx b/src/app/groups/[id]/layout.tsx index 6e061b4..77a7e43 100644 --- a/src/app/groups/[id]/layout.tsx +++ b/src/app/groups/[id]/layout.tsx @@ -18,7 +18,7 @@ export default function GroupDetailLayout({ const [isSidebarExpanded, setIsSidebarExpanded] = useState(false); // 공지사항 작성 페이지, 책장 작성 페이지, 회원 관리 페이지는 레이아웃 적용 X - if (pathname?.includes('/admin/notice/new') || pathname?.includes('/admin/bookcase/new') || pathname?.includes('/admin/members') || pathname?.includes('/admin/applicant')) { + if (pathname?.includes('/admin/notice/new') || pathname?.includes('/admin/bookcase') || pathname?.includes('/admin/members') || pathname?.includes('/admin/applicant')) { return <>{children}; } diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 76be133..3d5be6f 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -140,7 +140,7 @@ export default function AdminGroupHomePage() {
router.push(joinUrl)} + onClick={() => router.push(`${Number(groupId)}/notice/4`)} bgColorVar="--Primary_1" borderColorVar="--Primary_1" textColorVar="--White" 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" && ( -

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

- )} -
- -

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

-