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() {
- 등록
+ {createStoryMutation.isPending ? "등록 중..." : "등록"}
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 (
+
+
+ {/* 모바일 전용 뒤로가기 바 */}
+
router.back()}
+ className="
+ t:hidden
+ w-full
+ flex items-center gap-2
+ px-[10px] py-[12px]
+ border-b border-Gray-2
+ text-Gray-7 body_1_2
+ "
+ >
+
+
+
+ 뒤로가기
+
+
+ {/* 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({
-
+
- {/* 모바일: grid로 2줄 구성 (위: 프로필+이름+체크 / 아래: 내용)
- t 이상: flex 한 줄 */}
- {/* 프로필 + 이름: '한 덩어리' */}
- {/* t 이상에서만 내용이 같은 줄로 옴 */}
handleToggleCheck(id)}
- className="relative w-6 h-6 shrink-0 justify-self-end"
+ className="relative w-6 h-6 shrink-0 justify-self-end cursor-pointer hover:scale-[1.05]"
aria-label="체크 토글"
>
- {/* 모바일에서만: 내용이 아래로 내려감 (name 아래 라인) */}
@@ -385,10 +394,20 @@ export default function MeetingPage({
)}
+
+ {/* Floating 버튼 누르면 "채팅 모달" */}
+ iconSrc="/icons_chat.svg"
+ iconAlt="채팅"
+ onClick={() => setIsChatTeamModalOpen(true)}
+ className={`${isChatTeamModalOpen ? "mb-15 t:mb-0" : ""}`}
+ />
+
+ setIsChatTeamModalOpen(false)}
+ />
);
}
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"
- />
-
-
- {nameCheck === "checking" ? "확인중" : "중복확인"}
-
-
-
-
-
- 다른 이름을 입력하거나, 기수 또는 지역명을 추가해 구분해주세요.
-
- 예) 독서재량 2기, 독서재량 서울, 북적북적 인문학팀
-
-
-
- {nameCheck === "available" && (
-
사용 가능한 모임 이름입니다.
- )}
- {nameCheck === "duplicate" && (
-
이미 존재하는 모임 이름입니다.
- )}
-
-
-
- 모임의 소개글을 입력해주세요!
-
-
+
+ 독서 모임 이름을 입력해주세요!
+
+
+ {
+ 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"
+ />
+
+
+ {nameCheck === "checking" ? "확인중" : "중복확인"}
+
+
+
+
+ 다른 이름을 입력하거나, 기수 또는 지역명을 추가해 구분해주세요.
+
+ 예) 독서재량 2기, 독서재량 서울, 북적북적 인문학팀
+
+
+
+ {nameCheck === "available" && (
+
사용 가능한 모임 이름입니다.
+ )}
+ {nameCheck === "duplicate" && (
+
이미 존재하는 모임 이름입니다.
+ )}
+
+
+ 모임의 소개글을 입력해주세요!
+
)}
+
{/* STEP 2 */}
{step === 2 && (
-
-
- 모임의 프로필 사진을 업로드 해주세요!
-
-
-
- {/* profile preview */}
-
- {selectedImageUrl ? (
-
+
모임의 프로필 사진을 업로드 해주세요!
+
+
+
+ {selectedImageUrl ? (
+
+ ) : (
+ <>
+
- ) : (
- <>
- {/* mobile default */}
-
- {/* t~ desktop default */}
-
- >
- )}
-
-
- {/* action buttons */}
-
- {
- setSelectedImageUrl(null);
- setProfileMode("default");
- }}
- className={cx(
- "flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3",
- "hover:brightness-98 hover:-translate-y-[1px] cursor-pointer",
- profileMode === "default"
- ? "bg-primary-2 border-primary-2 text-White"
- : "bg-Subbrown-4 border-Subbrown-3 text-primary-3"
- )}
- >
- 기본 프로필 사용하기
-
-
- {
- setProfileMode("upload");
- fileRef.current?.click();
- }}
- className={cx(
- "flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3",
- "hover:brightness-98 hover:-translate-y-[1px] cursor-pointer",
- profileMode === "upload"
- ? "bg-primary-2 border-primary-1 text-White"
- : "bg-Subbrown-4 border-Subbrown-3 text-primary-3"
- )}
- >
- 사진 업로드하기
-
-
- {
- const f = e.target.files?.[0];
- if (f) {
- setProfileMode("upload");
- pickImage(f);
- }
- }}
- />
-
-
-
-
- 모임의 공개여부를 알려주세요!
-
-
-
- setVisibility("공개")}
- className="flex items-center gap-3 cursor-pointer select-none text-left"
- >
-
- 공개
-
-
- setVisibility("비공개")}
- className="flex items-center gap-3 cursor-pointer select-none text-left"
- >
-
- 비공개
-
+
+ >
+ )}
- {/* bottom buttons: mobile = 다음 풀폭, t~ = 이전/다음 오른쪽 정렬 */}
-
+
{
+ setSelectedImageUrl(null);
+ setProfileImageUrl(null);
+ setProfileMode("default");
+ if (fileRef.current) fileRef.current.value = "";
+ }}
className={cx(
- "hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]",
- "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
- "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
-
+ "flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3",
+ "hover:opacity-90 active:opacity-80",
+ profileMode === "default"
+ ? "bg-primary-1 border-primary-1 text-White"
+ : "bg-Subbrown-4 border-Subbrown-3 text-primary-3"
)}
>
- 이전
+ 기본 프로필 사용하기
{
+ setProfileMode("upload");
+ fileRef.current?.click();
+ }}
className={cx(
- "flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]",
- "w-full t:w-[148px]",
- "bg-primary-1 text-White hover:brightness-90 hover:-translate-y-[1px] cursor-pointer",
- "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed"
+ "flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3",
+ "hover:opacity-90 active:opacity-80",
+ profileMode === "upload"
+ ? "bg-primary-1 border-primary-1 text-White"
+ : "bg-Subbrown-4 border-Subbrown-3 text-primary-3"
)}
>
- 다음
+ 사진 업로드하기
+
+
{
+ const f = e.target.files?.[0];
+ if (f) {
+ setProfileMode("upload");
+ pickImage(f);
+ }
+ }}
+ />
+
+ {profileMode === "upload" && (
+
+ {uploadImage.isPending
+ ? "업로드 중..."
+ : profileImageUrl
+ ? ""
+ : "업로드 실패 시 다시 시도"}
+
+ )}
-
+
+
+
모임의 공개여부를 알려주세요!
+
+
+ setOpen(true)}
+ className="flex items-center gap-3 cursor-pointer select-none text-left"
+ >
+
+ 공개
+
+
+ setOpen(false)}
+ className="flex items-center gap-3 cursor-pointer select-none text-left"
+ >
+
+ 비공개
+
+
+
+
+
+ 이전
+
+
+
+ 다음
+
+
+
)}
+
{/* STEP 3 */}
{step === 3 && (
-
-
- 선호하는 독서 카테고리를 선택해주세요!{" "}
- (최대 6개)
-
-
-
- {BOOK_CATEGORIES.map((c) => (
- setSelectedCategories((prev) => toggleWithLimit(prev, c, 6))}
- />
- ))}
-
-
- 활동 지역을 입력해주세요!
- setActivityArea(e.target.value)}
- placeholder="활동 지역을 입력해주세요 (40자 제한)"
- className="mt-4 w-full h-[44px] t:h-[56px] rounded-[8px] border border-[#EAE5E2] body_1_3 bg-white px-4 outline-none"
- />
-
-
- 모임의 대상을 선택해주세요! (최대 6개)
-
-
-
- {PARTICIPANTS.map((p) => (
- setSelectedParticipants((prev) => toggleWithLimit(prev, p, 6))}
- />
- ))}
-
-
-
- {/* bottom buttons: mobile = 다음 풀폭, t~ = 이전/다음 오른쪽 정렬 */}
-
-
- 이전
-
-
-
- 다음
-
-
-
+
+
+ 선호하는 독서 카테고리를 선택해주세요!{" "}
+ (최대 6개)
+
+
+
+ {BOOK_CATEGORIES.map((c) => (
+ setSelectedCategories((prev) => toggleWithLimit(prev, c, 6))}
+ />
+ ))}
+
+
+ 활동 지역을 입력해주세요!
+ setActivityArea(e.target.value)}
+ placeholder="활동 지역을 입력해주세요 (40자 제한)"
+ className="mt-4 w-full h-[44px] t:h-[56px] rounded-[8px] border border-[#EAE5E2] body_1_3 bg-white px-4 outline-none"
+ />
+
+
+ 모임의 대상을 선택해주세요! (최대 6개)
+
+
+
+ {PARTICIPANTS.map((p) => (
+ setSelectedParticipants((prev) => toggleWithLimit(prev, p, 6))}
+ />
+ ))}
+
+
+
+
+ 이전
+
+
+
+ 다음
+
+
+
)}
{/* STEP 4 */}
{step === 4 && (
-
-
- SNS나 링크 연동이 있다면 해주세요! (선택)
-
-
-
- {links.map((it, idx) => (
-
+
SNS나 링크 연동이 있다면 해주세요! (선택)
+
+
+ {links.map((it, idx) => (
+
+
updateLink(idx, { label: e.target.value })}
+ placeholder="링크 대체 텍스트 입력(최대 20자)"
+ maxLength={20}
className="
- flex flex-col gap-4 py-3
- t:flex-row t:items-center
+ w-full t:w-[35%] h-[44px] t:h-[56px]
+ rounded-[8px]
+ border border-Subbrown-4
+ bg-White
+ px-4
+ outline-none
+ body_1_3
+ placeholder:text-Gray-3
"
- >
+ />
+
+
- ))}
-
-
-
-
-
-
-
-
-
-
- 이전
-
-
-
- 다음
-
-
+ type="button"
+ onClick={() => removeLinkRow(idx)}
+ disabled={links.length <= 1}
+ className="
+ w-[44px] h-[44px] t:w-[56px] t:h-[56px]
+ rounded-[8px]
+ bg-Gray-1
+ flex items-center justify-center
+ hover:bg-Gray-2
+ disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-Gray-1
+ "
+ title="삭제"
+ >
+
+
-
+
+ ))}
+
+
+
+
+
+
+
+
+ 이전
+
+
+
+ {createClub.isPending ? "생성 중..." : "모임 생성"}
+
+
+
)}
-
+
);
-}
+}
\ No newline at end of file
diff --git a/src/app/groups/groupSearchDummy.ts b/src/app/groups/groupSearchDummy.ts
deleted file mode 100644
index dd74f87..0000000
--- a/src/app/groups/groupSearchDummy.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-// Dummydata
-
-import type { GroupSummary } from '@/components/base-ui/Group-Search/search_mybookclub';
-import { ClubSummary } from './page';
-
-
-export const mydummyGroup: GroupSummary[] = [
- { id: '1', name: '모임1' },
- { id: '2', name: '모임2' },
- { id: '3', name: '모임3' },
- { id: '4', name: '모임4' },
- { id: '1', name: '모임11241' },
- { id: '2', name: '모임51212' },
- { id: '3', name: '모임125153' },
- { id: '4', name: '모임12512514' },
-
-];
-
-export const dummyClubs: ClubSummary[] = [
- {
- clubId: 201,
- name: "오늘도 한 페이지",
- profileImageUrl: null,
- category: [1,12,13],
- public: true,
- applytype: "Yes",
- region: "서울",
- participantTypes: ["STUDENT", "MEETING"],
- reason: "함께 성장하는 모임입니다.",
- },
- {
- clubId: 202,
- name: "퇴근 후 북클럽",
- profileImageUrl: null,
- category: [2,6],
- public: true,
- applytype: "Wait",
- region: "경기",
- participantTypes: ["WORKER", "OFFLINE"],
- reason: "퇴근 후 알찬 시간",
- },
- {
- clubId: 203,
- name: "새벽 독서단",
- profileImageUrl: null,
- category: [11,3],
- public: false,
- applytype: "Wait",
- region: "온라인",
- participantTypes: ["ONLINE"],
- reason: "새벽 기상 미션",
- },
- {
- clubId: 204,
- name: "고전 읽는 사람들",
- profileImageUrl: null,
- category: [4,12],
- public: true,
- applytype: "No",
- region: "부산",
- participantTypes: ["CLUB", "OFFLINE"],
- reason: "고전의 깊이",
- },
- {
- clubId: 205,
- name: "SF/판타지 탐험대",
- profileImageUrl: null,
- category: [13,5],
- public: true,
- applytype: "Yes",
- region: "대구",
- participantTypes: ["MEETING", "OFFLINE"],
- reason: "상상력을 자극하는 시간",
- },
- {
- clubId: 206,
- name: "비문학 한 주 한 권",
- profileImageUrl: null,
- category: [5,11,6],
- public: true,
- applytype: "Yes",
- region: "인천",
- participantTypes: ["WORKER", "MEETING"],
- reason: "지식을 넓히는 독서",
- },
- {
- clubId: 207,
- name: "자기계발 실천 모임",
- profileImageUrl: null,
- category: [7],
- public: false,
- applytype: "No",
- region: "온라인",
- participantTypes: ["ONLINE", "WORKER"],
- reason: "자기계발 끝판왕",
- },
- {
- clubId: 208,
- name: "시와 산책",
- profileImageUrl: null,
- category: [8],
- public: true,
- applytype: "Wait",
- region: "서울",
- participantTypes: ["STUDENT", "OFFLINE"],
- reason: "감성을 채우는 시간",
- },
- {
- clubId: 209,
- name: "추리소설 연구회",
- profileImageUrl: null,
- category: [9],
- public: true,
- applytype: "Yes",
- region: "대전",
- participantTypes: ["CLUB", "MEETING"],
- reason: "범인은 누구인가",
- },
- {
- clubId: 210,
- name: "에세이 공방",
- profileImageUrl: null,
- category: [10],
- public: true,
- applytype: "Wait",
- region: "광주",
- participantTypes: ["MEETING", "OFFLINE"],
- reason: "글쓰기의 즐거움",
- },
- {
- clubId: 211,
- name: "경제 뉴스 같이 읽기",
- profileImageUrl: null,
- category: [11],
- public: false,
- applytype: "No",
- region: "온라인",
- participantTypes: ["ONLINE", "WORKER"],
- reason: "경제를 보는 눈",
- },
- {
- clubId: 212,
- name: "철학 입문 스터디",
- profileImageUrl: null,
- category: [12],
- public: true,
- applytype: "Yes",
- region: "서울",
- participantTypes: ["STUDENT", "CLUB"],
- reason: "생각의 깊이를 더하다",
- },
- {
- clubId: 213,
- name: "영어 원서 챌린지",
- profileImageUrl: null,
- category: [13],
- public: true,
- applytype: "Wait",
- region: "온라인",
- participantTypes: ["ONLINE", "STUDENT"],
- reason: "영어 실력 향상",
- },
- {
- clubId: 214,
- name: "그림책 어른 모임",
- profileImageUrl: null,
- category: [14],
- public: true,
- applytype: "Yes",
- region: "경기",
- participantTypes: ["MEETING", "OFFLINE"],
- reason: "동심으로 돌아가는 시간",
- },
- {
- clubId: 215,
- name: "월간 책 수다방",
- profileImageUrl: null,
- category: [15],
- public: false,
- applytype: "Wait",
- region: "부산",
- participantTypes: ["CLUB", "OFFLINE"],
- reason: "책 수다의 매력",
- },
-];
diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx
index 934c5cf..2b9fbff 100644
--- a/src/app/groups/page.tsx
+++ b/src/app/groups/page.tsx
@@ -1,68 +1,254 @@
-'use client'
-import React, { useState } from 'react'
-import { useRouter } from 'next/navigation';
+"use client";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+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 from '@/components/base-ui/Group-Search/search_mybookclub'
+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 { mydummyGroup, dummyClubs} from './groupSearchDummy';
-import { ApplyType, Category, ParticipantType } from '@/types/groups/groups';
-import SearchClubListItem from '@/components/base-ui/Group-Search/search_clublist/search_clublist_item';
-import SearchClubApplyModal from '@/components/base-ui/Group-Search/search_club_apply_modal';
+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,
+ InputFilter,
+ OutputFilter,
+ RecommendationItemDTO,
+} from "@/types/groups/clubsearch";
-export type { Category, ParticipantType };
+import {
+ useClubRecommendationsQuery,
+ useInfiniteClubSearchQuery,
+ useMyClubsQuery,
+} from "@/hooks/queries/useSearchClubQueries";
+import { useClubJoinMutation } from "@/hooks/mutations/useSearchClubMutations";
+function mapCategoryToOutputFilter(category: Category): OutputFilter {
+ switch (category) {
+ case "대학생":
+ return "STUDENT";
+ case "직장인":
+ return "WORKER";
+ case "온라인":
+ return "ONLINE";
+ case "동아리":
+ return "CLUB";
+ case "모임":
+ return "MEETING";
+ case "대면":
+ return "OFFLINE";
+ case "전체":
+ default:
+ return "ALL";
+ }
+}
+
+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);
+}
+
+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 {
+ return {
+ reason,
+ clubId: club.clubId,
+ name: club.name,
+ profileImageUrl: club.profileImageUrl,
+ category: mapCategories(club.category),
+ public: club.open,
+ applytype: mapApplyType(myStatus),
+ region: club.region,
+ participantTypes: club.participantTypes
+ .map((p) => p.code as ParticipantType)
+ .filter(Boolean),
+ };
+}
-export interface ClubSummary {
- reason: string;
- clubId: number;
- name: string;
- profileImageUrl?: string | null; // 없으면 기본 이미지 쓰면 됨
- category: number[]; // 복수 가능
- public: boolean;
- applytype: ApplyType;
- region: string;
- participantTypes: ParticipantType[];
+function mapRecommendationItem(item: RecommendationItemDTO): ClubSummary {
+ const reason = `겹치는 모임 ${item.overlapCount}개 · 활동멤버 ${item.activeMemberCount}명`;
+ return mapClubDTOToSummary(item.clubInfo.club, item.clubInfo.myStatus, reason);
+}
+
+function mapSearchItem(item: ClubListItemDTO): ClubSummary {
+ return mapClubDTOToSummary(item.club, item.myStatus);
}
export default function Searchpage() {
- // 검색창 요소들
- const [q, setQ] = useState('');
- const [category,setCategory] = useState('전체');
- const [group, setGroup] = useState(false);
- const [region, setRegion] = useState(false);
+ const router = useRouter();
+
+ const [q, setQ] = useState("");
+ const [category, setCategory] = useState("전체");
+ const [group, setGroup] = useState(false);
+ const [region, setRegion] = useState(false);
+
+ const [appliedParams, setAppliedParams] = useState | null>(null);
+
+ // 추천/검색 모드 판단
+ const isSearchMode = appliedParams !== null;
+ // ===== 가입 모달 =====
const [applyClubId, setApplyClubId] = useState(null);
- const selectedClub = dummyClubs.find((c) => c.clubId === applyClubId) ?? null;
- // 모달
- const router = useRouter();
-
- const onClickVisit = (clubId: number) => {
- router.push(`/groups/${clubId}`);
- };
+ // ===== Queries =====
+ const {
+ data: myClubsData,
+ isLoading: myClubsLoading,
+ } = useMyClubsQuery();
+
+ const { data: recData, isLoading: recLoading } = useClubRecommendationsQuery(!isSearchMode);
+
+ // 검색은 appliedParams 있을 때만 실행
+ const {
+ data: searchData,
+ isFetching: searchFetching,
+ hasNextPage,
+ fetchNextPage,
+ refetch: refetchSearch,
+ } = useInfiniteClubSearchQuery(
+ appliedParams ?? { keyword: undefined, inputFilter: null, outputFilter: "ALL" },
+ isSearchMode
+ );
+
+ // ===== Mutation =====
+ const { mutateAsync: joinAsync, isPending: joinPending } =
+ useClubJoinMutation();
- const onClickApply = (clubId: number) => {
+ // ===== UI 데이터 변환 =====
+ const myGroups: GroupSummary[] = useMemo(() => {
+ const list = myClubsData?.clubList ?? [];
+ return list.map((c) => ({ id: String(c.clubId), name: c.clubName }));
+ }, [myClubsData]);
+
+ const recommendationClubs: ClubSummary[] = useMemo(() => {
+ const list = recData?.recommendations ?? [];
+ return list.map(mapRecommendationItem);
+ }, [recData]);
+
+ const searchedClubs: ClubSummary[] = useMemo(() => {
+ const pages = searchData?.pages ?? [];
+ return pages.flatMap((p) => p.clubList.map(mapSearchItem));
+ }, [searchData]);
+
+ const clubsToRender = isSearchMode ? searchedClubs : recommendationClubs;
+ const selectedClub =
+ clubsToRender.find((c) => c.clubId === applyClubId) ?? null;
+
+ const sentinelRef = useRef(null);
+
+ useEffect(() => {
+ if (!isSearchMode) return;
+ const el = sentinelRef.current;
+ if (!el) return;
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ const first = entries[0];
+ if (!first?.isIntersecting) return;
+ if (hasNextPage && !searchFetching) fetchNextPage();
+ },
+ { root: null, rootMargin: "200px", threshold: 0 }
+ );
+
+ io.observe(el);
+ 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 onCloseApply = () => setApplyClubId(null);
- const onSubmitApply = (clubId: number, reason: string) => {
+ const onSubmitApply = async (clubId: number, reason: string) => {
if (!reason.trim()) return;
+ if (joinPending) return;
- console.log('apply:', clubId, reason);
- // TODO API
- setApplyClubId(null);
+ try {
+ const msg = await joinAsync({
+ clubId,
+ body: { joinMessage: reason.trim() },
+ });
+ toast.success(msg);
+ setApplyClubId(null);
+ } catch (e: any) {
+ toast.error(e?.message ?? "가입 신청에 실패했습니다.");
+ }
};
+const onSubmitSearch = () => {
+ const keyword = q.trim();
+
+ // 검색없으면 추천모드로
+ if (!keyword) {
+ setAppliedParams(null);
+ return;
+ }
+ setAppliedParams({
+ outputFilter: mapCategoryToOutputFilter(category),
+ inputFilter: mapInputFilter(group, region),
+ keyword: keyword,
+ });
+
+ refetchSearch();
+};
return (
-
-
+
- 독서 모임
+ 독서 모임
+
{/* 모바일 */}
router.push('/groups/create')}
- />
+ className="flex-1 body_1 hover:brightness-95 hover:-translate-y-[1px] cursor-pointer"
+ onClick={() => router.push("/groups/create")}
+ />
{/* 테블릿 이상 */}
- router.push('/groups/create')}
+ onClick={() => router.push("/groups/create")}
/>
-
-
+ {/* 로딩 중에도 로고 나오게 */}
+
-
+
-
모임 검색하기
-
+
모임 검색하기
+
console.log(q, category, group, region)}
+ onSubmit={onSubmitSearch}
category={category}
setCategory={setCategory}
group={group}
@@ -112,35 +298,39 @@ export default function Searchpage() {
-
- {dummyClubs.map((club) => (
-
- ))}
-
+
+ {clubsToRender.map((club) => (
+
+ ))}
+
+
+ {/* 추천 로딩 */}
+ {!isSearchMode && recLoading && (
+
불러오는 중…
+ )}
+
+ {/* 검색 무한 스크롤 sentinel */}
+ {isSearchMode &&
}
-
{/* 태블릿 이상: 기존 모달 */}
-
-
-
-
-
- )
-}
+ );
+}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 05f0892..04e6c06 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,6 +4,7 @@ import BottomNav from "@/components/layout/BottomNav";
import { Toaster } from "react-hot-toast";
import { AuthProvider } from "@/components/auth/AuthProvider";
import { HeaderTitleProvider } from "@/contexts/HeaderTitleContext";
+import Providers from "@/app/providers";
import "@/app/globals.css";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
@@ -44,13 +45,15 @@ export default function RootLayout({
-
-
- {children}
-
-
-
-
+
+
+
+ {children}
+
+
+
+
+
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
new file mode 100644
index 0000000..c2f3181
--- /dev/null
+++ b/src/app/providers.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { useState } from "react";
+
+export default function Providers({ children }: { children: React.ReactNode }) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ // Default settings for queries
+ staleTime: 60 * 1000, // 1 minute
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+ })
+ );
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx
index 15a9e3f..2e8dbb7 100644
--- a/src/components/base-ui/BookStory/bookstory_card.tsx
+++ b/src/components/base-ui/BookStory/bookstory_card.tsx
@@ -16,19 +16,7 @@ type Props = {
subscribeText?: string;
};
-function timeAgo(iso: string) {
- const diff = Date.now() - new Date(iso).getTime();
- const minutes = Math.floor(diff / 60000);
-
- if (minutes < 1) return "방금";
- if (minutes < 60) return `${minutes}분 전`;
-
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}시간 전`;
-
- const days = Math.floor(hours / 24);
- return `${days}일 전`;
-}
+import { formatTimeAgo } from "@/utils/time";
export default function BookStoryCard({
authorName,
@@ -65,7 +53,7 @@ export default function BookStoryCard({
{authorName}
- {timeAgo(createdAt)} 조회수 {viewCount}
+ {formatTimeAgo(createdAt)} 조회수 {viewCount}
void;
};
-function timeAgo(iso: string) {
- const diff = Date.now() - new Date(iso).getTime();
- const minutes = Math.floor(diff / 60000);
-
- if (minutes < 1) return "방금";
- if (minutes < 60) return `${minutes}분 전`;
-
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}시간 전`;
-
- const days = Math.floor(hours / 24);
- return `${days}일 전`;
-}
+import { formatTimeAgo } from "@/utils/time";
export default function BookStoryCardLarge({
authorName,
@@ -65,7 +53,7 @@ export default function BookStoryCardLarge({
{authorName}
- {timeAgo(createdAt)} 조회수 {viewCount}
+ {formatTimeAgo(createdAt)} 조회수 {viewCount}
{
+ if (!draggable) return;
+ e.dataTransfer.setData(
+ "application/x-checkmo-member",
+ JSON.stringify({
+ clubMemberId: member.clubMemberId,
+ fromTeamNumber: member.teamNumber,
+ })
+ );
+ e.dataTransfer.effectAllowed = "move";
+ }}
+ className={[
+ "flex items-center gap-2.5",
+ "w-full self-stretch",
+ "px-5 py-4",
+ "rounded-[8px]",
+ "bg-White",
+ draggable ? "cursor-grab active:cursor-grabbing" : "",
+ ].join(" ")}
+ >
+
+
+
+
+
+ {member.memberInfo.nickname}
+
+
+ );
+}
diff --git a/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
new file mode 100644
index 0000000..b8ad014
--- /dev/null
+++ b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useDraggable, useDroppable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+import { TeamMember } from "@/types/groups/bookcasedetail";
+
+const DEFAULT_PROFILE = "/profile.svg";
+
+type Props = {
+ unassigned: TeamMember[];
+
+ isDragOverPool: boolean;
+ onDragOverPool: (isOver: boolean) => void;
+
+ // dnd-kit에서는 이동 처리를 page.tsx의 DndContext(onDragEnd)에서 하는 게 정석이라,
+ // 여기서는 안 씀(그래도 props 시그니처 유지)
+ onMoveMember: (clubMemberId: number, toTeamNumber: number | null) => void;
+
+ onSubmit: () => void;
+};
+
+function DraggableUnassignedCard({
+ member,
+ highlighted,
+}: {
+ member: TeamMember;
+ highlighted: boolean;
+}) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: `member-${member.clubMemberId}`,
+ data: {
+ clubMemberId: member.clubMemberId,
+ fromTeamNumber: member.teamNumber,
+ },
+ });
+
+ const style: React.CSSProperties = {
+ transform: CSS.Translate.toString(transform),
+ opacity: isDragging ? 0.6 : 1,
+ };
+
+ return (
+
+
+
+
+
+
{member.memberInfo.nickname}
+
+ );
+}
+
+export default function MemberPool({
+ unassigned,
+ isDragOverPool,
+ onDragOverPool,
+ onMoveMember: _onMoveMember, // 사용 안 함(린트용)
+ onSubmit,
+}: Props) {
+ const router = useRouter();
+ const [isConfirmOpen, setIsConfirmOpen] = useState(false);
+
+ // ✅ pool droppable
+ const { setNodeRef, isOver } = useDroppable({ id: "pool" });
+
+ // 기존 하이라이트 상태 관리랑 맞춰주기
+ useEffect(() => {
+ onDragOverPool(isOver);
+ }, [isOver, onDragOverPool]);
+
+ const highlighted = isDragOverPool || isOver;
+
+ const handleConfirmYes = () => {
+ onSubmit();
+ setIsConfirmOpen(false);
+ router.back();
+ };
+
+ return (
+
+ {/* 드랍 영역 전체(오른쪽 박스) */}
+
+ {/* 헤더 */}
+
+ 토론 참여자
+
+
+ {/* 목록 */}
+
+
+ {unassigned.length === 0 ? (
+
+ ) : (
+ unassigned.map((m) => (
+
+ ))
+ )}
+
+
+
+ {/* 저장 버튼 */}
+
+ setIsConfirmOpen(true)}
+ className={[
+ "w-full h-[44px] rounded-[8px]",
+ "bg-primary-2 text-white body_1_2",
+ "cursor-pointer hover:brightness-90 active:brightness-90",
+ "transition",
+ ].join(" ")}
+ >
+ 조 편성 저장
+
+
+
+
+ {/* 확인 모달 */}
+ {isConfirmOpen && (
+ <>
+
setIsConfirmOpen(false)}
+ />
+
+
+
수정하시겠습니까?
+
+
+
+ 예
+
+ setIsConfirmOpen(false)}
+ className={[
+ "flex-1 h-[44px] rounded-[8px]",
+ "border border-Subbrown-4",
+ "text-Gray-7 body_1_2",
+ "cursor-pointer hover:brightness-90 active:brightness-90",
+ ].join(" ")}
+ >
+ 아니오
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx
new file mode 100644
index 0000000..9968430
--- /dev/null
+++ b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { useEffect } from "react";
+import Image from "next/image";
+import { useDroppable, useDraggable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+
+import { MAX_TEAMS, teamLabel, TeamMember } from "@/types/groups/bookcasedetail";
+import MemberItem from "./MemberItem";
+
+type Props = {
+ teams: number[];
+ members: TeamMember[];
+
+ dragOverTeamNumber: number | null;
+ onDragOverTeam: (teamNumber: number | null) => void;
+
+ onAddTeam: () => void;
+ onRemoveTeam: (teamNumber: number) => void;
+
+ // dnd-kit에선 이동 처리를 page.tsx onDragEnd에서 하는 게 정석이라 여기선 안 씀
+ onMoveMember: (clubMemberId: number, toTeamNumber: number | null) => void;
+};
+
+function DraggableMember({ member }: { member: TeamMember }) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: `member-${member.clubMemberId}`,
+ data: {
+ clubMemberId: member.clubMemberId,
+ fromTeamNumber: member.teamNumber,
+ },
+ });
+
+ const style: React.CSSProperties = {
+ transform: CSS.Translate.toString(transform),
+ opacity: isDragging ? 0.6 : 1,
+ };
+
+ return (
+
+ {/* ✅ UI는 기존 MemberItem 그대로 재사용 */}
+
+
+ );
+}
+
+function TeamDropCard({
+ teamNumber,
+ label,
+ teamsLength,
+ teamMembers,
+ dragOverTeamNumber,
+ onDragOverTeam,
+ onRemoveTeam,
+}: {
+ teamNumber: number;
+ label: string;
+ teamsLength: number;
+ teamMembers: TeamMember[];
+ dragOverTeamNumber: number | null;
+ onDragOverTeam: (teamNumber: number | null) => void;
+ onRemoveTeam: (teamNumber: number) => void;
+}) {
+ const { setNodeRef, isOver } = useDroppable({ id: `team-${teamNumber}` });
+
+ // 기존 하이라이트 상태(dragOverTeamNumber)를 쓰고 있길래 동기화만 해줌
+ useEffect(() => {
+ if (isOver) onDragOverTeam(teamNumber);
+ else if (dragOverTeamNumber === teamNumber) onDragOverTeam(null);
+ }, [isOver, teamNumber, dragOverTeamNumber, onDragOverTeam]);
+
+ const highlighted = isOver || dragOverTeamNumber === teamNumber;
+
+ return (
+
+ {/* 위쪽 박스 */}
+
+
+ {label}조
+
+
+
onRemoveTeam(teamNumber)}
+ disabled={teamsLength <= 1}
+ className={["hover:brightness-50 cursor-pointer hover:scale-[1.07]"].join(" ")}
+ aria-label="팀 삭제"
+ title="팀 삭제"
+ >
+
+
+
+
+
+
+ {/* 인물들 */}
+
+ {teamMembers.length === 0 ? (
+
+ 여기로 드래그해서 추가
+
+ ) : (
+ teamMembers.map((m) =>
)
+ )}
+
+
+ );
+}
+
+export default function TeamBoard({
+ teams,
+ members,
+ dragOverTeamNumber,
+ onDragOverTeam,
+ onAddTeam,
+ onRemoveTeam,
+ onMoveMember: _onMoveMember, // 사용 안 함(린트용)
+}: Props) {
+ const canAdd = teams.length < MAX_TEAMS;
+
+ return (
+
+ {/* 상단 */}
+
+
+ {/* 팀 리스트 */}
+
+ {teams.map((teamNumber) => {
+ const label = teamLabel(teamNumber);
+ const teamMembers = members.filter((m) => m.teamNumber === teamNumber);
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/base-ui/Bookcase/BookDetailNav.tsx b/src/components/base-ui/Bookcase/BookDetailNav.tsx
index 1d38145..70ec3b7 100644
--- a/src/components/base-ui/Bookcase/BookDetailNav.tsx
+++ b/src/components/base-ui/Bookcase/BookDetailNav.tsx
@@ -2,20 +2,27 @@
import React from "react";
-type Tab = "발제" | "한줄평" | "정기모임";
+export type Tab = "topic" | "review" | "meeting";
type Props = {
activeTab: Tab;
onTabChange: (tab: Tab) => void;
};
-export default function BookDetailNav({ activeTab, onTabChange }: Props) {
- const tabs: Tab[] = ["발제", "한줄평", "정기모임"];
+const TABS: Tab[] = ["topic", "review", "meeting"];
+
+const TAB_LABEL: Record
= {
+ topic: "발제",
+ review: "한줄평",
+ meeting: "정기모임",
+};
+export default function BookDetailNav({ activeTab, onTabChange }: Props) {
return (
-
- {tabs.map((tab) => {
+
+ {TABS.map((tab) => {
const isActive = activeTab === tab;
+
return (
- {tab}
+ {TAB_LABEL[tab]}
);
})}
diff --git a/src/components/base-ui/Bookcase/BookcaseCard.tsx b/src/components/base-ui/Bookcase/BookcaseCard.tsx
index 7ad0f7b..29d7c79 100644
--- a/src/components/base-ui/Bookcase/BookcaseCard.tsx
+++ b/src/components/base-ui/Bookcase/BookcaseCard.tsx
@@ -3,7 +3,7 @@
import Image from "next/image";
type Props = {
- imageUrl?: string;
+ imageUrl: string;
title: string;
author: string;
category: {
diff --git a/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx b/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx
new file mode 100644
index 0000000..f46b747
--- /dev/null
+++ b/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import Image from "next/image";
+
+export type ChatTeam = {
+ teamId: string;
+ teamName: string;
+ memberCount: number;
+};
+
+type Props = {
+ isOpen: boolean;
+ teams: ChatTeam[];
+ onClose: () => void;
+ title?: string;
+};
+
+const ICON_CLOSE = "/icon_minus_1.svg"; // 24x24
+const ICON_ARROW_RIGHT = "/ArrowRight2.svg"; // 24x24
+const ICON_BACK = "/ArrowLeft3.svg"; // 24x24
+const ICON_SEND = "/Send.svg"; // 24x24
+
+function clamp(n: number, min: number, max: number) {
+ return Math.min(Math.max(n, min), max);
+}
+
+function useIsTabletUp() {
+ const [isTabletUp, setIsTabletUp] = useState(false);
+
+ useEffect(() => {
+ const mql = window.matchMedia("(min-width: 768px)");
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setIsTabletUp(mql.matches);
+
+ const onChange = (e: MediaQueryListEvent) => setIsTabletUp(e.matches);
+
+ if (mql.addEventListener) {
+ mql.addEventListener("change", onChange);
+ return () => mql.removeEventListener("change", onChange);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (mql as any).addListener?.(onChange);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return () => (mql as any).removeListener?.(onChange);
+ }, []);
+
+ return isTabletUp;
+}
+
+type ViewMode = "select" | "chat";
+
+const HOVER_ICON =
+ "cursor-pointer transition-[filter,transform] duration-150 ease-out hover:brightness-50 ";
+const HOVER_SURFACE =
+ "cursor-pointer transition-[filter,transform] duration-150 ease-out hover:brightness-95 ";
+const HOVER_INPUT_WRAPPER =
+ "cursor-pointer transition-[filter] duration-150 ease-out hover:brightness-95";
+
+export default function ChatTeamSelectModal({
+ isOpen,
+ teams,
+ onClose,
+ title = "채팅 조 선택",
+}: Props) {
+ const isTabletUp = useIsTabletUp();
+ const panelRef = useRef
(null);
+
+ const [view, setView] = useState("select");
+ const [activeTeam, setActiveTeam] = useState(null);
+
+ const [pos, setPos] = useState({ x: 0, y: 0 });
+ const posRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ useEffect(() => {
+ posRef.current = pos;
+ }, [pos]);
+
+ const dragRef = useRef({
+ dragging: false,
+ startX: 0,
+ startY: 0,
+ originX: 0,
+ originY: 0,
+ });
+
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ setView("select");
+ setActiveTeam(null);
+
+ if (!isTabletUp) {
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }
+ return;
+ }, [isOpen, isTabletUp]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ if (!isTabletUp) return;
+
+ const raf = requestAnimationFrame(() => {
+ const panel = panelRef.current;
+ const w = panel?.offsetWidth ?? 366;
+ const h = panel?.offsetHeight ?? 716;
+
+ const margin = 8;
+ const maxX = Math.max(margin, window.innerWidth - w - margin);
+ const maxY = Math.max(margin, window.innerHeight - h - margin);
+
+ const cx = clamp((window.innerWidth - w) / 2, margin, maxX);
+ const cy = clamp((window.innerHeight - h) / 2, margin, maxY);
+
+ setPos({ x: cx, y: cy });
+ });
+
+ return () => cancelAnimationFrame(raf);
+ }, [isOpen, isTabletUp]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const onMove = (e: PointerEvent) => {
+ if (!dragRef.current.dragging) return;
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ const w = panel.offsetWidth;
+ const h = panel.offsetHeight;
+
+ const margin = 8;
+ const maxX = Math.max(margin, window.innerWidth - w - margin);
+ const maxY = Math.max(margin, window.innerHeight - h - margin);
+
+ const dx = e.clientX - dragRef.current.startX;
+ const dy = e.clientY - dragRef.current.startY;
+
+ const nx = clamp(dragRef.current.originX + dx, margin, maxX);
+ const ny = clamp(dragRef.current.originY + dy, margin, maxY);
+
+ setPos({ x: nx, y: ny });
+ };
+
+ const onUp = () => {
+ dragRef.current.dragging = false;
+ };
+
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+
+ return () => {
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ };
+ }, [isOpen]);
+
+ const handleHeaderPointerDown = (e: React.PointerEvent) => {
+ if (!isTabletUp) return;
+ if (e.button !== 0) return;
+
+ dragRef.current.dragging = true;
+ dragRef.current.startX = e.clientX;
+ dragRef.current.startY = e.clientY;
+ dragRef.current.originX = posRef.current.x;
+ dragRef.current.originY = posRef.current.y;
+ };
+
+ const handleSelectTeam = (team: ChatTeam) => {
+ setActiveTeam(team);
+ setView("chat");
+ };
+
+ const handleBack = () => {
+ setView("select");
+ setActiveTeam(null);
+ };
+
+ const focusInput = () => {
+ inputRef.current?.focus();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* ===== 헤더 ===== */}
+ {view === "select" ? (
+
+
{title}
+
+ e.stopPropagation()}
+ className={["w-6 h-6 shrink-0 flex items-center justify-center", HOVER_ICON].join(" ")}
+ aria-label="닫기"
+ >
+
+
+
+ ) : (
+
+
e.stopPropagation()}
+ className={["w-6 h-6 flex items-center justify-center", HOVER_ICON].join(" ")}
+ aria-label="뒤로"
+ >
+
+
+
+
+ {activeTeam?.teamName ?? ""}
+
+
+
e.stopPropagation()}
+ className={["w-6 h-6 flex items-center justify-center justify-self-end", HOVER_ICON].join(
+ " "
+ )}
+ aria-label="닫기"
+ >
+
+
+
+ )}
+
+ {/* ===== 본문 ===== */}
+ {view === "select" ? (
+
+ {teams.map((team) => (
+ handleSelectTeam(team)}
+ className={[
+ "w-full flex items-center justify-between",
+ "p-3 rounded-[8px]",
+ "bg-Subbrown-4 text-Gray-7 body_1_2",
+ HOVER_SURFACE,
+ ].join(" ")}
+ >
+ {team.teamName}
+
+
+
+ ))}
+
+ ) : (
+ <>
+ {/* 채팅 영역 */}
+
+
+
+ 채팅 UI 영역 (메시지 리스트 들어올 자리)
+
+
+
+
+ {/* 입력창 */}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/base-ui/Bookcase/MeetingInfo.tsx b/src/components/base-ui/Bookcase/MeetingInfo.tsx
index 687f58a..2bfa91b 100644
--- a/src/components/base-ui/Bookcase/MeetingInfo.tsx
+++ b/src/components/base-ui/Bookcase/MeetingInfo.tsx
@@ -3,9 +3,11 @@
import Image from "next/image";
type Props = {
+ meetingId?: number;
meetingName: string;
- date: string; // 예: "2000.00.00"
- location: string; // 예: "제이스 스터디룸"
+ date: string; // "2026.02.10"
+ location: string;
+ isAdmin?: boolean;
onManageGroupClick?: () => void;
};
@@ -13,6 +15,7 @@ export default function MeetingInfo({
meetingName,
date,
location,
+ isAdmin = false,
onManageGroupClick,
}: Props) {
return (
@@ -21,52 +24,29 @@ export default function MeetingInfo({
{/* 상단: 모임 이름 + 조 관리 버튼 */}
- {/* 모임 이름 영역 */}
-
-
-
-
-
-
- {/* 모임 이름: Subhead_4.1 */}
-
{meetingName}
-
-
+ {/* 모임 이름 */}
+
+ {meetingName}
- {/* 조 관리하기 버튼 */}
-
- 조 관리하기
-
-
-
-
+
조 관리하기
+
+
+
+
+ )}
- {/* 하단: 일정 및 장소 정보 */}
-
- {/* 일정 */}
-
-
- {/* 장소 */}
-
+ {/* 하단: 날짜 / 장소 */}
+
+ {date}
+ {location}
diff --git a/src/components/base-ui/Comment/comment_item.tsx b/src/components/base-ui/Comment/comment_item.tsx
index 5bdf30c..419e616 100644
--- a/src/components/base-ui/Comment/comment_item.tsx
+++ b/src/components/base-ui/Comment/comment_item.tsx
@@ -44,6 +44,8 @@ export default function CommentItem({
onReport,
}: CommentItemProps) {
const [menuOpen, setMenuOpen] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editContent, setEditContent] = useState(content);
const menuRef = useRef
(null);
// 바깥 클릭 시 메뉴 닫기
@@ -57,7 +59,19 @@ export default function CommentItem({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
- // 댓글 본체 (프로필 + 이름 + 내용 + 날짜 + 메뉴)
+ const handleSaveEdit = () => {
+ if (editContent.trim() && editContent !== content) {
+ onEdit?.(id, editContent);
+ }
+ setIsEditing(false);
+ };
+
+ const handleCancelEdit = () => {
+ setEditContent(content);
+ setIsEditing(false);
+ };
+
+ // 댓글 본체 (프로필 + 이름 + 작성자 + 날짜 + 내용 + 메뉴)
const commentBody = (
{/* 상단: 프로필 + 이름 + 작성자 + 날짜 */}
@@ -82,106 +96,134 @@ export default function CommentItem({
{/* 댓글 내용 + 메뉴 (같은 줄) */}
-
-
{content}
- {/* 메뉴 버튼 */}
-
-
setMenuOpen(!menuOpen)}
- className="p-1 cursor-pointer"
- >
-
-
+ {!isEditing ? (
+
+
{content}
+ {/* 메뉴 버튼 */}
+
+
setMenuOpen(!menuOpen)}
+ className="p-1 cursor-pointer"
+ >
+
+
- {/* 드롭다운 메뉴 */}
- {menuOpen && (
-
- {/* 답글달기 - onReply가 있을 때만 표시 */}
- {!isReply && onReply && (
- <>
-
{
- onReply(id);
- setMenuOpen(false);
- }}
- className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
- >
-
- 답글달기
-
-
- >
- )}
- {/* 내 댓글일 때: 수정, 삭제 */}
- {isMine ? (
- <>
- {onDelete && (
+ {/* 드롭다운 메뉴 */}
+ {menuOpen && (
+
+ {/* 답글달기 - onReply가 있을 때만 표시 */}
+ {!isReply && onReply && (
+ <>
{
- onDelete(id);
+ onReply(id);
setMenuOpen(false);
}}
className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
>
-
- 삭제하기
+
+ 답글달기
- )}
- {onEdit && onDelete && (
- )}
- {onEdit && (
-
{
- onEdit(id, content);
- setMenuOpen(false);
- }}
- className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
- >
-
- 수정하기
-
- )}
- >
- ) : (
- /* 남의 댓글일 때 */
-
{
- if (!isAdminView) {
- onReport?.(id);
- }
- setMenuOpen(false);
- }}
- className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
- >
-
- {isAdminView ? "가리기" : "신고하기"}
-
- )}
-
- )}
+ >
+ )}
+ {/* 내 댓글일 때: 수정, 삭제 */}
+ {isMine ? (
+ <>
+ {onDelete && (
+
{
+ onDelete(id);
+ setMenuOpen(false);
+ }}
+ className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
+ >
+
+ 삭제하기
+
+ )}
+ {onEdit && onDelete && (
+
+ )}
+ {onEdit && (
+
{
+ setIsEditing(true);
+ setMenuOpen(false);
+ }}
+ className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
+ >
+
+ 수정하기
+
+ )}
+ >
+ ) : (
+ /* 남의 댓글일 때 */
+
{
+ if (!isAdminView) {
+ onReport?.(id);
+ }
+ setMenuOpen(false);
+ }}
+ className="flex w-full h-[44px] items-center justify-center gap-2 Body_1_2 text-Gray-4 hover:bg-Gray-1 cursor-pointer"
+ >
+
+ {isAdminView ? "가리기" : "신고하기"}
+
+ )}
+
+ )}
+
-
+ ) : (
+ /* 수정 모드 */
+
+ )}
);
diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx
index 66b7689..b1e6a41 100644
--- a/src/components/base-ui/Comment/comment_section.tsx
+++ b/src/components/base-ui/Comment/comment_section.tsx
@@ -1,101 +1,174 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import CommentList, { Comment } from "./comment_list";
+import { CommentInfo } from "@/types/story";
+import { isValidUrl } from "@/utils/url";
+import {
+ useCreateCommentMutation,
+ useUpdateCommentMutation,
+ useDeleteCommentMutation
+} from "@/hooks/mutations/useStoryMutations";
+import { toast } from "react-hot-toast";
+import ConfirmModal from "@/components/common/ConfirmModal";
-// 더미 댓글 데이터
-const DUMMY_COMMENTS: Comment[] = [
- {
- id: 1,
- authorName: "hy_1234",
- profileImgSrc: "/profile2.svg",
- content: "인정합니다.",
- createdAt: "2025-09-22T10:00:00",
- isAuthor: true, // 글 작성자
- isMine: true, // 내가 쓴 댓글
- },
- {
- id: 3,
- authorName: "hy-123456",
- profileImgSrc: "/profile4.svg",
- content: "인정합니다.",
- createdAt: "2025-09-22T12:00:00",
- isAuthor: false,
- isMine: false,
- },
-];
-
-// 어떤 글의 댓글인지 구분(나중에 api 연동 시 사용)
+// 어떤 글의 댓글인지 구분
type CommentSectionProps = {
storyId: number;
+ initialComments?: CommentInfo[];
+ storyAuthorNickname?: string;
};
-export default function CommentSection({ storyId }: CommentSectionProps) {
- const [comments, setComments] = useState(DUMMY_COMMENTS);
+export default function CommentSection({
+ storyId,
+ initialComments = [],
+ storyAuthorNickname
+}: CommentSectionProps) {
+ const createCommentMutation = useCreateCommentMutation(storyId);
+ const updateCommentMutation = useUpdateCommentMutation(storyId);
+ const deleteCommentMutation = useDeleteCommentMutation(storyId);
+
+ // API 데이터를 UI용 Comment 형식으로 변환 및 계층 구조화
+ const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => {
+ const flatComments: Comment[] = apiComments.map((c) => ({
+ id: c.commentId,
+ authorName: c.authorInfo.nickname,
+ profileImgSrc: isValidUrl(c.authorInfo.profileImageUrl)
+ ? c.authorInfo.profileImageUrl
+ : "/profile2.svg",
+ content: c.content,
+ createdAt: c.createdAt,
+ isAuthor: c.authorInfo.nickname === storyAuthorNickname,
+ isMine: c.writtenByMe,
+ replies: [],
+ }));
+
+ const rootComments: Comment[] = [];
+ const commentMap = new Map();
+
+ flatComments.forEach(c => commentMap.set(c.id, c));
+
+ apiComments.forEach((c, index) => {
+ const uiComment = flatComments[index];
+ if (c.parentCommentId && commentMap.has(c.parentCommentId)) {
+ commentMap.get(c.parentCommentId)!.replies!.push(uiComment);
+ } else {
+ rootComments.push(uiComment);
+ }
+ });
+
+ // 최상위 댓글 최신순(내림차순) 정렬
+ rootComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+
+ // 대댓글은 등록순(오름차순) 유지 (일반적인 UI 패턴)
+ rootComments.forEach(c => {
+ c.replies?.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
+ });
+
+ return rootComments;
+ };
+
+ const [comments, setComments] = useState(() => mapApiToUiComments(initialComments));
+ const [isConfirmOpen, setIsConfirmOpen] = useState(false);
+ const [commentToDelete, setCommentToDelete] = useState(null);
+
+ // 데이터가 변경되면 상태 업데이트
+ useEffect(() => {
+ setComments(mapApiToUiComments(initialComments));
+ }, [initialComments, storyAuthorNickname]);
+
+ // 댓글 추가
const handleAddComment = (content: string) => {
- const newComment: Comment = {
- id: Date.now(),
- authorName: "유빈", // TODO: 실제 로그인 유저 정보
- profileImgSrc: "/profile2.svg",
- content,
- createdAt: new Date().toISOString(),
- isAuthor: false,
- isMine: true, // 내가 쓴 댓글
- };
- setComments([newComment, ...comments]);
+ createCommentMutation.mutate(
+ { content },
+ {
+ onSuccess: () => {
+ toast.success("댓글이 등록되었습니다.");
+ },
+ onError: () => {
+ toast.error("댓글 등록에 실패했습니다.");
+ }
+ }
+ );
};
+ // 답글 추가
const handleAddReply = (parentId: number, content: string) => {
- // 새 답글 생성
- const newReply: Comment = {
- id: Date.now(),
- authorName: "유빈",
- profileImgSrc: "/profile2.svg",
- content,
- createdAt: new Date().toISOString(),
- isAuthor: false,
- isMine: true,
- };
-
- // 원래 댓글 찾아서 replies 배열에 답글 추가
- setComments((prevComments) => {
- return prevComments.map((comment) => {
- if (comment.id === parentId) {
- // 원래 댓글의 replies 배열에 새 답글 추가
- return {
- ...comment,
- replies: [...(comment.replies || []), newReply],
- };
+ createCommentMutation.mutate(
+ { content, parentCommentId: parentId },
+ {
+ onSuccess: () => {
+ toast.success("답글이 등록되었습니다.");
+ },
+ onError: () => {
+ toast.error("답글 등록에 실패했습니다.");
}
- return comment;
- });
- });
+ }
+ );
};
const handleEditComment = (id: number, content: string) => {
-
+ updateCommentMutation.mutate(
+ { commentId: id, content },
+ {
+ onSuccess: () => {
+ toast.success("댓글이 수정되었습니다.");
+ },
+ onError: () => {
+ toast.error("댓글 수정에 실패했습니다.");
+ }
+ }
+ );
};
const handleDeleteComment = (id: number) => {
- setComments(comments.filter((c) => c.id !== id));
+ setCommentToDelete(id);
+ setIsConfirmOpen(true);
+ };
+
+ const confirmDelete = () => {
+ if (commentToDelete === null) return;
+ deleteCommentMutation.mutate(commentToDelete, {
+ onSuccess: () => {
+ toast.success("댓글이 삭제되었습니다.");
+ setIsConfirmOpen(false);
+ setCommentToDelete(null);
+ },
+ onError: () => {
+ toast.error("댓글 삭제에 실패했습니다.");
+ setIsConfirmOpen(false);
+ setCommentToDelete(null);
+ }
+ });
};
const handleReportComment = (id: number) => {
- // 신고 API 연동
+ // TODO: 댓글 신고 API 연동
console.log("댓글 신고:", id);
- alert("신고가 접수되었습니다.");
+ toast.success("신고가 접수되었습니다.");
};
return (
-
+ <>
+
+ {
+ setIsConfirmOpen(false);
+ setCommentToDelete(null);
+ }}
+ />
+ >
);
}
diff --git a/src/components/base-ui/Group-Search/search_club_apply_modal.tsx b/src/components/base-ui/Group-Search/search_club_apply_modal.tsx
index a375f68..5875153 100644
--- a/src/components/base-ui/Group-Search/search_club_apply_modal.tsx
+++ b/src/components/base-ui/Group-Search/search_club_apply_modal.tsx
@@ -1,65 +1,56 @@
-'use client';
+"use client";
-import { ClubSummary } from '@/app/groups/page';
-import Image from 'next/image';
-import { useEffect, useRef, useState } from 'react';
-import ClubCategoryTags from './search_clublist/search_club_category_tags';
+import type { ClubSummary } from "@/components/base-ui/Group-Search/search_clublist/search_clublist_item";
+import Image from "next/image";
+import { useEffect, useRef, useState } from "react";
+import ClubCategoryTags from "./search_clublist/search_club_category_tags";
-
-const DEFAULT_CLUB_IMG = '/ClubDefaultImg.svg';
+const DEFAULT_CLUB_IMG = "/ClubDefaultImg.svg";
const PARTICIPANT_KO: Record = {
- STUDENT: '대학생',
- WORKER: '직장인',
- ONLINE: '온라인',
- CLUB: '동아리',
- MEETING: '모임',
- OFFLINE: '대면',
+ STUDENT: "대학생",
+ WORKER: "직장인",
+ ONLINE: "온라인",
+ CLUB: "동아리",
+ MEETING: "모임",
+ OFFLINE: "대면",
};
type Props = {
open: boolean;
club: ClubSummary | null;
onClose: () => void;
- onSubmit: (club: number, reason: string) => void;
+ onSubmit: (clubId: number, reason: string) => void;
};
-export default function SearchClubApplyModal({
- open,
- club,
- onClose,
- onSubmit,
-}: Props) {
- const [reason, setReason] = useState('');
+export default function SearchClubApplyModal({ open, club, onClose, onSubmit }: Props) {
+ const [reason, setReason] = useState("");
const taRef = useRef(null);
useEffect(() => {
- if (open) setReason('');
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ if (open) setReason("");
}, [open]);
- // ESC로 닫기
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
+ if (e.key === "Escape") onClose();
};
- window.addEventListener('keydown', onKeyDown);
- return () => window.removeEventListener('keydown', onKeyDown);
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
- // textarea auto-resize: 스크롤 없고, 내용만큼 커짐 -> 모달도 같이 커짐
const autoResize = () => {
const el = taRef.current;
if (!el) return;
- el.style.height = 'auto';
+ el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
};
useEffect(() => {
if (!open) return;
autoResize();
- // 열리자마자 높이 맞추기
- // requestAnimationFrame으로 폰트/레이아웃 적용 이후 한번 더
requestAnimationFrame(autoResize);
}, [open]);
@@ -67,28 +58,23 @@ export default function SearchClubApplyModal({
const imgSrc = club.profileImageUrl ?? DEFAULT_CLUB_IMG;
const participantText =
- club.participantTypes?.map((t) => PARTICIPANT_KO[t] ?? t).join(', ') ?? '-';
+ club.participantTypes?.map((t) => PARTICIPANT_KO[t] ?? t).join(", ") ?? "-";
return (
- {/* overlay */}
- {/* center */}
-
- {/* 내용 영역 */}
- {/* 왼쪽: 이미지 + 텍스트 */}
- {/* 이름 */}
{club.name}
-
- {/* 카테고리 */}
- {/* 모임대상 / 활동지역 */}
-
+
모임 대상 : {participantText}
-
+
활동 지역 : {club.region}
- {/* 오른쪽 */}
-
+
-
- {club.public ? '공개' : '비공개'}
-
-
+ {club.public ? "공개" : "비공개"}
+
-
- {/* textarea: ✅ auto-resize, 스크롤 없이 모달이 길어짐 */}
- {/* 하단 버튼 */}
onSubmit(club.clubId,reason)}
+ onClick={() => onSubmit(club.clubId, reason)}
className={[
- 'w-[132px] h-[40px] px-4 rounded-[8px]',
- 'bg-primary-2 text-White border border-primary-2',
- 'body_1_2 hover:brightness-90 cursor-pointer',
- ].join(' ')}
+ "h-[40px] px-4 rounded-[8px]",
+ "bg-primary-2 text-White border border-primary-2",
+ "body_1_2",
+ ].join(" ")}
disabled={reason.trim().length === 0}
>
가입 신청하기
@@ -181,4 +152,4 @@ export default function SearchClubApplyModal({
);
-}
+}
\ 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 f77e043..d0d11c1 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
@@ -1,30 +1,42 @@
-'use client';
+"use client";
-import Image from 'next/image';
-import React from 'react';
-import ClubCategoryTags from './search_club_category_tags';
-import { ClubSummary } from '@/app/groups/page';
+import Image from "next/image";
+import React from "react";
+import ClubCategoryTags from "./search_club_category_tags";
+import type { ApplyType, ParticipantType } from "@/types/groups/groups";
-
-const DEFAULT_CLUB_IMG = '/ClubDefaultImg.svg';
+const DEFAULT_CLUB_IMG = "/ClubDefaultImg.svg";
// participantTypes 한글 매핑
const PARTICIPANT_KO: Record
= {
- STUDENT: '대학생',
- WORKER: '직장인',
- ONLINE: '온라인',
- CLUB: '동아리',
- MEETING: '모임',
- OFFLINE: '대면',
+ STUDENT: "대학생",
+ WORKER: "직장인",
+ ONLINE: "온라인",
+ CLUB: "동아리",
+ MEETING: "모임",
+ OFFLINE: "대면",
};
const APPLY_META: Record<
- ClubSummary['applytype'],
+ ApplyType,
{ label?: string; icon?: string; labelClass?: string }
> = {
No: {},
- Wait: { label: '신청 완료', icon: '/GreenCheck.svg', labelClass: 'text-green-600' },
- Yes: { label: '가입 됨', icon: '/BrownCheck.svg', labelClass: 'text-primary-3' },
+ Wait: { label: "신청 완료", icon: "/GreenCheck.svg", labelClass: "text-green-600" },
+ 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[];
+ public: boolean;
+ applytype: ApplyType;
+ region: string;
+ participantTypes: ParticipantType[];
};
type Props = {
@@ -37,6 +49,17 @@ type Props = {
applyOpenId: number | null;
};
+function safeImageSrc(src?: string | null) {
+ if (!src) return DEFAULT_CLUB_IMG;
+
+ if (src === "string") return DEFAULT_CLUB_IMG;
+ if (src.startsWith("/")) return src;
+ if (src.startsWith("http://") || src.startsWith("https://")) return src;
+
+ return DEFAULT_CLUB_IMG;
+}
+
+
export default function SearchClubListItem({
club,
onClickVisit,
@@ -45,30 +68,30 @@ export default function SearchClubListItem({
onCloseApply,
onSubmitApply,
}: Props) {
- const imgSrc = club.profileImageUrl ?? DEFAULT_CLUB_IMG;
+
+ const imgSrc = safeImageSrc(club.profileImageUrl);
const isOpen = applyOpenId === club.clubId;
- const [reason, setReason] = React.useState('');
+ const [reason, setReason] = React.useState("");
React.useEffect(() => {
- if (!isOpen) setReason('');
+ if (!isOpen) setReason("");
}, [isOpen]);
const participantText = club.participantTypes
.map((t) => PARTICIPANT_KO[t] ?? t)
- .join(', ');
+ .join(", ");
const applyMeta = APPLY_META[club.applytype];
return (
- {/* 상단(기존 카드 내용)만 가로 배치 */}
{/* Left - Tablet */}
@@ -80,7 +103,6 @@ export default function SearchClubListItem({
className="shrink-0 rounded-[8px] object-cover"
/>
- {/* 이름, 태그 */}
{club.name}
@@ -88,10 +110,9 @@ export default function SearchClubListItem({
- {/* 모임 대상, 지역 */}
- 모임 대상 : {participantText || '-'}
+ 모임 대상 : {participantText || "-"}
활동 지역 : {club.region}
@@ -101,65 +122,44 @@ export default function SearchClubListItem({
{/* Left - Mobile */}
-
- {/* 제목/태그 + 상태(모바일에서 오른쪽 글 영역) */}
-
-
+
+
-
-
-
{club.public ? '공개' : '비공개'}
-
+
+
+
+
+ 모임 대상 : {participantText || "-"}
+
+
+ 활동 지역 : {club.region}
+
-
- {applyMeta.label && (
-
-
- {applyMeta.label}
-
- {applyMeta.icon && }
-
- )}
-
-
-
- {/* 이미지 + 정보 (이제 오른쪽 버튼이 없어서 풀폭으로 널찍해짐) */}
-
-
-
-
- 모임 대상 : {participantText || '-'}
-
-
- 활동 지역 : {club.region}
-
-
- {/* RIGHT (Tablet~) */}
-
- {/* 공개/비공개 + 상태 */}
+ {/* RIGHT */}
+
- {club.public ? '공개' : '비공개'}
-
+ {club.public ? "공개" : "비공개"}
+
{applyMeta.label && (
-
+
{applyMeta.label}
{applyMeta.icon && }
@@ -167,22 +167,21 @@ export default function SearchClubListItem({
)}
- {/* 버튼 */}
-
- {club.applytype === 'No' && (
+
+ {club.applytype === "No" && (
onClickApply?.(club.clubId)}
className={[
- 'h-[28px] t:h-[40px] px-4 py-2 w-full',
- 'flex items-center justify-center',
- 'rounded-[8px]',
- 'bg-primary-2 text-White hover:brightness-90 cursor-pointer',
- 'body_2_2 t:body_1_2',
- 'mb-1',
- ].join(' ')}
+ "h-[28px] t:h-[40px] px-4 py-2 w-full",
+ "flex items-center justify-center",
+ "rounded-[8px]",
+ "bg-primary-2 text-White",
+ "body_2_2 t:body_1_2",
+ "mb-1",
+ ].join(" ")}
>
- {isOpen ? '신청 닫기' : '가입신청하기'}
+ {isOpen ? "신청 닫기" : "가입신청하기"}
가입신청하기
)}
@@ -191,13 +190,13 @@ export default function SearchClubListItem({
type="button"
onClick={() => onClickVisit?.(club.clubId)}
className={[
- 'h-[28px] t:h-[40px] px-4 py-2 w-full',
- 'flex items-center justify-center gap-[10px]',
- 'rounded-[8px]',
- 'border border-primary-1',
- 'bg-background text-primary-3 hover:brightness-95 cursor-pointer',
- 'body_2_2 t:body_1_2',
- ].join(' ')}
+ "h-[28px] t:h-[40px] px-4 py-2 w-full",
+ "flex items-center justify-center gap-[10px]",
+ "rounded-[8px]",
+ "border border-primary-1",
+ "bg-background text-primary-3",
+ "body_2_2 t:body_1_2",
+ ].join(" ")}
>
방문하기
@@ -205,36 +204,8 @@ export default function SearchClubListItem({
-
- {club.applytype === 'No' && (
- onClickApply?.(club.clubId)}
- className={[
- 'flex-1 h-[28px] rounded-[10px] body_2_1',
- 'bg-primary-2 text-White hover:brightness-90 cursor-pointer',
- ].join(' ')}
- >
- {isOpen ? '신청 닫기' : '가입신청하기'}
-
- )}
-
- onClickVisit?.(club.clubId)}
- className={[
- club.applytype === 'No' ? 'flex-1' : 'w-full',
- 'h-[28px] rounded-[10px] body_2_1',
- 'border border-primary-1 bg-background text-primary-3 hover:brightness-95 cursor-pointer',
- ].join(' ')}
- >
- 방문하기
-
-
-
-
{/* ✅ 모바일에서만: 아이템 아래로 확장 신청폼 */}
- {club.applytype === 'No' && isOpen && (
+ {club.applytype === "No" && isOpen && (
);
}
diff --git a/src/components/base-ui/home/list_subscribe_large.tsx b/src/components/base-ui/home/list_subscribe_large.tsx
index 0345855..aecd648 100644
--- a/src/components/base-ui/home/list_subscribe_large.tsx
+++ b/src/components/base-ui/home/list_subscribe_large.tsx
@@ -4,8 +4,8 @@ import Image from 'next/image';
type ListSubscribeElementLargeProps = {
name: string;
- subscribingCount: number;
- subscribersCount: number;
+ subscribingCount?: number;
+ subscribersCount?: number;
profileSrc?: string;
onSubscribeClick?: () => void;
buttonText?: string;
@@ -35,9 +35,11 @@ function ListSubscribeElementLarge({
{name}
-
- 구독중 {subscribingCount} 구독자 {subscribersCount}
-
+ {subscribingCount !== undefined && subscribersCount !== undefined && (
+
+ 구독중 {subscribingCount} 구독자 {subscribersCount}
+
+ )}
;
};
-export default function ListSubscribeLarge({ height = 'h-[380px]' }: ListSubscribeLargeProps) {
- 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 },
+export default function ListSubscribeLarge({
+ height = "h-[380px]",
+ users: propUsers,
+}: ListSubscribeLargeProps) {
+ // Use prop users if provided, otherwise fallback to default dummy data
+ const users = propUsers || [
+ { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
+ { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
+ { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
+ { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 },
];
return (
-
+
사용자 추천
{users.map((u) => (
console.log('subscribe', u.id)}
+ profileSrc={u.profileImageUrl}
+ onSubscribeClick={() => console.log("subscribe", u.nickname)}
/>
))}
diff --git a/src/components/common/ConfirmModal.tsx b/src/components/common/ConfirmModal.tsx
new file mode 100644
index 0000000..dbbee40
--- /dev/null
+++ b/src/components/common/ConfirmModal.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useEffect } from "react";
+
+type ConfirmModalProps = {
+ isOpen: boolean;
+ message: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+export default function ConfirmModal({ isOpen, message, onConfirm, onCancel }: ConfirmModalProps) {
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isOpen) {
+ onCancel();
+ }
+ };
+
+ if (isOpen) {
+ document.body.style.overflow = "hidden";
+ document.addEventListener("keydown", handleEscape);
+ } else {
+ document.body.style.overflow = "";
+ }
+
+ return () => {
+ document.body.style.overflow = "";
+ document.removeEventListener("keydown", handleEscape);
+ };
+ }, [isOpen, onCancel]);
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* 백그라운드 딤 */}
+
+ {/* 모달 컨테이너 */}
+
e.stopPropagation()}
+ >
+
+
+
+ 취소
+
+ {
+ onConfirm();
+ onCancel();
+ }}
+ className="flex-1 h-[48px] rounded-lg bg-primary-3 text-White body_1_2 hover:bg-primary-3/90 transition-colors"
+ >
+ 확인
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/layout/BookSelectModal.tsx b/src/components/layout/BookSelectModal.tsx
index a2cd8e8..f7ac1bd 100644
--- a/src/components/layout/BookSelectModal.tsx
+++ b/src/components/layout/BookSelectModal.tsx
@@ -1,14 +1,18 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useMemo } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
+import { useInfiniteBookSearchQuery } from "@/hooks/queries/useBookQueries";
+import { useDebounce } from "@/hooks/useDebounce";
import SearchBookResult from "@/components/base-ui/Search/search_bookresult";
+import { useInView } from "react-intersection-observer";
+import { Book } from "@/types/book";
type BookSelectModalProps = {
isOpen: boolean;
onClose: () => void;
- onSelect: (bookId: number) => void;
+ onSelect: (isbn: string) => void;
};
export default function BookSelectModal({
@@ -16,41 +20,32 @@ export default function BookSelectModal({
onClose,
onSelect,
}: BookSelectModalProps) {
- const router = useRouter();
- const [likedResults, setLikedResults] = useState>({});
+ const [likedResults, setLikedResults] = useState>({});
const [searchValue, setSearchValue] = useState("");
+ const debouncedSearchValue = useDebounce(searchValue, 500);
- // 더미 데이터
- 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(debouncedSearchValue);
- const handleSearch = () => {
- if (searchValue.trim()) {
- router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`);
- setSearchValue("");
- onClose();
+ 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 handleSearch = () => {
+ // 실시간 검색으로 대체
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -136,33 +131,48 @@ export default function BookSelectModal({
{/* 검색 결과 */}
-
- 총 {searchResults.length}개
- 의 검색결과가 있습니다.
-
-
- {searchResults.map((result) => (
-
- setLikedResults((prev) => ({ ...prev, [result.id]: liked }))
- }
- onPencilClick={() => {
- onSelect(result.id);
- onClose();
- }}
- onCardClick={() => {
- onSelect(result.id);
- onClose();
- }}
- />
- ))}
-
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ 총 {searchResults.length}개
+ 의 검색결과가 있습니다.
+
+
+ {searchResults.map((result: Book) => (
+
+ setLikedResults((prev) => ({ ...prev, [result.isbn]: liked }))
+ }
+ onPencilClick={() => {
+ onSelect(result.isbn);
+ onClose();
+ }}
+ onCardClick={() => {
+ onSelect(result.isbn);
+ onClose();
+ }}
+ />
+ ))}
+
+
+ {/* 무한 스크롤 로딩 트리거 */}
+
+ {isFetchingNextPage && (
+
+ )}
+
+ >
+ )}
diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx
index 92f5c0d..743bec3 100644
--- a/src/components/layout/SearchModal.tsx
+++ b/src/components/layout/SearchModal.tsx
@@ -1,10 +1,14 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useMemo } from "react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Search_BookCoverCard from "@/components/base-ui/Search/search_recommendbook";
+import { useInfiniteBookSearchQuery, useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries";
+import { useDebounce } from "@/hooks/useDebounce";
+import { useInView } from "react-intersection-observer";
+import { Book } from "@/types/book";
type SearchModalProps = {
isOpen: boolean;
@@ -14,16 +18,37 @@ type SearchModalProps = {
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const router = useRouter();
const [topOffset, setTopOffset] = useState(0);
- const [likedBooks, setLikedBooks] = useState
>({});
+ const [likedBooks, setLikedBooks] = useState>({});
const [searchValue, setSearchValue] = useState("");
+ const debouncedSearchValue = useDebounce(searchValue, 300);
- // 더미 추천 책 데이터
- const recommendedBooks = [
- { id: 1, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" },
- { id: 2, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" },
- { id: 3, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" },
- { id: 4, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" },
- ];
+ const {
+ data: searchData,
+ isLoading: isSearching,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ } = useInfiniteBookSearchQuery(debouncedSearchValue);
+
+ 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 { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendedBooksQuery();
+
+ const recommendedBooks = useMemo(() => {
+ return (recommendedData?.detailInfoList || []).slice(0, 4);
+ }, [recommendedData]);
+
+ const booksToDisplay = searchResults;
const handleSearch = () => {
if (searchValue.trim()) {
@@ -80,93 +105,152 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
onClick={onClose}
style={{ top: `${topOffset}px` }}
/>
-
+
{/* 모달 */}
-
-
-
-
-
-
-
setSearchValue(e.target.value)}
- onKeyDown={handleKeyDown}
- className="w-full h-full bg-transparent text-white subhead_4 t:subhead_1 d:headline_3 placeholder:subbrown_3 focus:outline-none pr-10"
- autoFocus
- />
- {searchValue && (
-
setSearchValue("")}
- className="absolute right-2.5 flex items-center justify-center shrink-0 z-10 w-4 h-4 t:w-6 t:h-6 d:w-7 d:h-7"
- aria-label="검색어 지우기"
- >
-
-
- )}
+
+
+
+
+
+
+ setSearchValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="w-full h-full bg-transparent text-white subhead_4 t:subhead_1 d:headline_3 placeholder:subbrown_3 focus:outline-none pr-10"
+ autoFocus
+ />
+ {searchValue && (
+ setSearchValue("")}
+ className="absolute right-2.5 flex items-center justify-center shrink-0 z-10 w-4 h-4 t:w-6 t:h-6 d:w-7 d:h-7"
+ aria-label="검색어 지우기"
+ >
+
+
+ )}
+
-
-
+
- {/* 오늘의 추천 책 */}
-
-
-
오늘의 추천 책
-
- {recommendedBooks.slice(0, 4).map((book, index) => (
-
-
- setLikedBooks((prev) => ({ ...prev, [book.id]: liked }))
- }
- />
-
- ))}
+ {/* 실시간 검색 결과 리스트 */}
+ {searchValue.trim() !== "" && (
+
+
+ {isSearching ? (
+
검색 중...
+ ) : booksToDisplay.length > 0 ? (
+
+ {booksToDisplay.map((book: Book) => (
+
{
+ router.push(`/books/${book.isbn}`);
+ onClose();
+ }}
+ className="flex items-center gap-4 p-3 hover:bg-white/10 cursor-pointer rounded-lg transition-colors"
+ >
+
+
+
+
+ {book.title}
+ {book.author}
+
+
+ ))}
+ {/* 무한 스크롤 로딩 트리거 */}
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+ ) : (
+ debouncedSearchValue &&
검색 결과가 없습니다.
+ )}
+
-
-
-
-
알라딘 랭킹 더 보러가기
-
-
+ )}
+
+ {/* 오늘의 추천 책 - 검색어가 없을 때만 표시 */}
+ {!searchValue.trim() && (
+ <>
+
+
+
오늘의 추천 책
+
+ {isLoadingRecommended ? (
+
추천 도서를 불러오는 중...
+ ) : recommendedBooks.length > 0 ? (
+ recommendedBooks.map((book, index) => (
+
+
+ setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked }))
+ }
+ onCardClick={() => {
+ router.push(`/books/${book.isbn}`);
+ onClose();
+ }}
+ />
+
+ ))
+ ) : (
+
추천 도서가 없습니다.
+ )}
+
+
+
+
+
알라딘 랭킹 더 보러가기
+
+
+
+
+
-
-
+ >
+ )}
-
>
);
}
diff --git a/src/hooks/mutations/useCreateClubMutation.ts b/src/hooks/mutations/useCreateClubMutation.ts
new file mode 100644
index 0000000..04d84d7
--- /dev/null
+++ b/src/hooks/mutations/useCreateClubMutation.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { clubService } from "@/services/clubService";
+import { imageService } from "@/services/imageService";
+import { CreateClubRequest } from "@/types/groups/clubCreate";
+import { useMutation } from "@tanstack/react-query";
+
+
+export const useUploadClubImageMutation = () => {
+ return useMutation({
+ mutationFn: (file: File) => imageService.uploadClubImage(file),
+ });
+};
+
+export const useCreateClubMutation = () => {
+ return useMutation({
+ mutationFn: (payload: CreateClubRequest) => clubService.createClub(payload),
+ });
+};
\ No newline at end of file
diff --git a/src/hooks/mutations/useSearchClubMutations.ts b/src/hooks/mutations/useSearchClubMutations.ts
new file mode 100644
index 0000000..ace2458
--- /dev/null
+++ b/src/hooks/mutations/useSearchClubMutations.ts
@@ -0,0 +1,13 @@
+"use client";
+
+import { useMutation } from "@tanstack/react-query";
+import { clubService } from "@/services/clubService";
+import type { ClubJoinRequest } from "@/types/groups/clubsearch";
+
+type Vars = { clubId: number; body: ClubJoinRequest };
+
+export function useClubJoinMutation() {
+ return useMutation
({
+ mutationFn: ({ clubId, body }) => clubService.joinClub(clubId, body),
+ });
+}
\ No newline at end of file
diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts
new file mode 100644
index 0000000..21fc4bf
--- /dev/null
+++ b/src/hooks/mutations/useStoryMutations.ts
@@ -0,0 +1,46 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { storyService } from "@/services/storyService";
+import { CreateBookStoryRequest, storyKeys } from "@/hooks/queries/useStoryQueries";
+
+export const useCreateBookStoryMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateBookStoryRequest) => storyService.createBookStory(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.all });
+ },
+ });
+};
+
+export const useCreateCommentMutation = (bookStoryId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (args: { content: string; parentCommentId?: number }) =>
+ storyService.createComment(bookStoryId, { content: args.content }, args.parentCommentId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
+ },
+ });
+};
+
+export const useUpdateCommentMutation = (bookStoryId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (args: { commentId: number; content: string }) =>
+ storyService.updateComment(bookStoryId, args.commentId, { content: args.content }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
+ },
+ });
+};
+
+export const useDeleteCommentMutation = (bookStoryId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (commentId: number) =>
+ storyService.deleteComment(bookStoryId, commentId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
+ },
+ });
+};
diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts
new file mode 100644
index 0000000..5acebdd
--- /dev/null
+++ b/src/hooks/queries/useBookQueries.ts
@@ -0,0 +1,47 @@
+import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
+import { bookService } from "@/services/bookService";
+
+export const bookKeys = {
+ all: ["books"] as const,
+ search: (title: string) => [...bookKeys.all, "search", title] as const,
+ infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const,
+ recommend: () => [...bookKeys.all, "recommend"] as const,
+ detail: (isbn: string) => [...bookKeys.all, "detail", isbn] as const,
+};
+
+export const useBookSearchQuery = (keyword: string) => {
+ return useQuery({
+ queryKey: bookKeys.search(keyword),
+ queryFn: () => bookService.searchBooks(keyword),
+ enabled: keyword.trim().length > 0,
+ });
+};
+
+export const useInfiniteBookSearchQuery = (keyword: string) => {
+ return useInfiniteQuery({
+ queryKey: bookKeys.infiniteSearch(keyword),
+ queryFn: ({ pageParam }) => bookService.searchBooks(keyword, pageParam),
+ initialPageParam: 1,
+ enabled: keyword.trim().length > 0,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.currentPage + 1;
+ },
+ });
+};
+
+export const useRecommendedBooksQuery = () => {
+ return useQuery({
+ queryKey: bookKeys.recommend(),
+ queryFn: () => bookService.getRecommendedBooks(),
+ staleTime: 1000 * 60 * 60, // 1 hour (recommended books don't change often)
+ });
+};
+
+export const useBookDetailQuery = (isbn: string) => {
+ return useQuery({
+ queryKey: bookKeys.detail(isbn),
+ queryFn: () => bookService.getBookDetail(isbn),
+ enabled: !!isbn,
+ });
+};
diff --git a/src/hooks/queries/useCreateClubQueries.ts b/src/hooks/queries/useCreateClubQueries.ts
new file mode 100644
index 0000000..4f972f0
--- /dev/null
+++ b/src/hooks/queries/useCreateClubQueries.ts
@@ -0,0 +1,17 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { clubService } from "@/services/clubService";
+
+export const useClubNameCheckQuery = (clubName: string) => {
+ const name = clubName.trim();
+
+ return useQuery({
+ queryKey: ["clubs", "check-name", name],
+ queryFn: async () => {
+ const res = await clubService.checkNameDuplicate(name);
+ return res.result;
+ },
+ enabled: false,
+ });
+};
\ No newline at end of file
diff --git a/src/hooks/queries/useMemberQueries.ts b/src/hooks/queries/useMemberQueries.ts
new file mode 100644
index 0000000..15c73a4
--- /dev/null
+++ b/src/hooks/queries/useMemberQueries.ts
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import { memberService } from "@/services/memberService";
+
+export const memberKeys = {
+ all: ["members"] as const,
+ recommended: () => [...memberKeys.all, "recommended"] as const,
+};
+
+export const useRecommendedMembersQuery = (enabled: boolean = true) => {
+ return useQuery({
+ queryKey: memberKeys.recommended(),
+ queryFn: () => memberService.getRecommendedMembers(),
+ enabled,
+ });
+};
diff --git a/src/hooks/queries/useSearchClubQueries.ts b/src/hooks/queries/useSearchClubQueries.ts
new file mode 100644
index 0000000..6ab2191
--- /dev/null
+++ b/src/hooks/queries/useSearchClubQueries.ts
@@ -0,0 +1,63 @@
+"use client";
+
+import {
+ useInfiniteQuery,
+ useQuery,
+ type InfiniteData,
+} from "@tanstack/react-query";
+import { clubService } from "@/services/clubService";
+import type {
+ ClubSearchParams,
+ ClubSearchResult,
+ MyClubsResult,
+ RecommendationsResult,
+} from "@/types/groups/clubsearch";
+
+export const clubQueryKeys = {
+ myClubs: ["clubs", "my"] as const,
+ recommendations: ["clubs", "recommendations"] as const,
+ search: (params: Omit) =>
+ ["clubs", "search", params] as const,
+};
+
+export function useMyClubsQuery() {
+ return useQuery({
+ queryKey: clubQueryKeys.myClubs,
+ queryFn: clubService.getMyClubsV2,
+ });
+}
+
+export function useClubRecommendationsQuery(enabled: boolean) {
+ return useQuery({
+ queryKey: clubQueryKeys.recommendations,
+ queryFn: clubService.getRecommendations,
+ enabled,
+ });
+}
+
+export function useInfiniteClubSearchQuery(
+ params: Omit,
+ enabled: boolean
+) {
+ return useInfiniteQuery<
+ ClubSearchResult,
+ Error,
+ InfiniteData,
+ ReturnType,
+ number | undefined
+ >({
+ queryKey: clubQueryKeys.search(params),
+ enabled,
+
+ initialPageParam: undefined,
+
+ queryFn: ({ pageParam }) =>
+ clubService.searchClubs({
+ ...params,
+ cursorId: pageParam, // undefined면 service가 제거해줌
+ }),
+
+ getNextPageParam: (lastPage) =>
+ lastPage.hasNext ? (lastPage.nextCursor ?? undefined) : undefined,
+ });
+}
\ No newline at end of file
diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts
new file mode 100644
index 0000000..94c6871
--- /dev/null
+++ b/src/hooks/queries/useStoryQueries.ts
@@ -0,0 +1,37 @@
+import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
+import { storyService } from "@/services/storyService";
+export type { CreateBookStoryRequest } from "@/types/story";
+
+export const storyKeys = {
+ all: ["stories"] as const,
+ list: () => [...storyKeys.all, "list"] as const,
+ infiniteList: () => [...storyKeys.all, "infiniteList"] as const,
+ detail: (id: number) => [...storyKeys.all, "detail", id] as const,
+};
+
+export const useStoriesQuery = () => {
+ return useQuery({
+ queryKey: storyKeys.list(),
+ queryFn: () => storyService.getAllStories(),
+ });
+};
+
+export const useStoryDetailQuery = (id: number) => {
+ return useQuery({
+ queryKey: storyKeys.detail(id),
+ queryFn: () => storyService.getStoryById(id),
+ enabled: !!id,
+ });
+};
+
+export const useInfiniteStoriesQuery = () => {
+ return useInfiniteQuery({
+ queryKey: storyKeys.infiniteList(),
+ queryFn: ({ pageParam }) => storyService.getAllStories(pageParam ?? undefined),
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.nextCursor;
+ },
+ });
+};
diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..596df10
--- /dev/null
+++ b/src/hooks/useDebounce.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useState, useEffect } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index 7b987f8..281d1a5 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -100,6 +100,8 @@ export const apiClient = {
request(url, { ...options, method: "POST", body: body ? JSON.stringify(body) : undefined }),
put: (url: string, body?: any, options?: RequestOptions) =>
request(url, { ...options, method: "PUT", body: body ? JSON.stringify(body) : undefined }),
+ patch: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "PATCH", body: body ? JSON.stringify(body) : undefined }),
delete: (url: string, options?: RequestOptions) =>
request(url, { ...options, method: "DELETE" }),
};
diff --git a/src/lib/api/client/index.ts b/src/lib/api/client/index.ts
new file mode 100644
index 0000000..b496695
--- /dev/null
+++ b/src/lib/api/client/index.ts
@@ -0,0 +1,105 @@
+"use client";
+
+import { useAuthStore } from "@/store/useAuthStore";
+import toast from "react-hot-toast";
+import { getErrorMessage, ApiError } from "../errors";
+
+interface RequestOptions extends RequestInit {
+ headers?: Record;
+ params?: Record;
+ timeout?: number; // Timeout in ms (default: 10000)
+}
+
+async function request(
+ url: string,
+ options: RequestOptions = {}
+): Promise {
+ const { params, timeout = 10000, ...fetchOptions } = options;
+
+ const defaultHeaders: Record = {
+ "Content-Type": "application/json",
+ };
+
+ // [Utility] Query String Builder
+ let requestUrl = url;
+ if (params) {
+ const searchParams = new URLSearchParams();
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ searchParams.append(key, String(value));
+ }
+ });
+ requestUrl += `?${searchParams.toString()}`;
+ }
+
+ // [Resilience] Timeout Controller
+ const controller = new AbortController();
+ const id = setTimeout(() => controller.abort(), timeout);
+
+ const config: RequestInit = {
+ ...fetchOptions,
+ // [Security] Include credentials (cookies) for all requests
+ credentials: "include",
+ headers: {
+ ...defaultHeaders,
+ ...options.headers,
+ },
+ signal: controller.signal,
+ };
+
+ try {
+ const response = await fetch(requestUrl, config);
+ clearTimeout(id);
+
+ // [Resilience] Interceptor: 401 Unauthorized Handling
+ if (response.status === 401) {
+ console.warn("Session expired. Logging out...");
+ useAuthStore.getState().logout();
+ toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");
+ }
+
+ // [Resilience] Safe JSON Parsing
+ let data: any;
+ const contentType = response.headers.get("content-type");
+ if (contentType && contentType.includes("application/json")) {
+ data = await response.json();
+ } else {
+ data = {
+ isSuccess: false,
+ message: "서버 응답 형식이 올바르지 않습니다.",
+ };
+ }
+
+ // [Standardization] Response Normalization
+ if (!response.ok || (data && data.isSuccess === false)) {
+ const errorCode = data?.code || `HTTP${response.status}`;
+ const errorMessage =
+ data?.message ||
+ getErrorMessage(errorCode) ||
+ "요청 처리 중 오류가 발생했습니다.";
+
+ throw new ApiError(errorMessage, errorCode, data);
+ }
+
+ return data;
+ } catch (error) {
+ clearTimeout(id);
+ console.error("API Request Error:", error);
+ if (error instanceof DOMException && error.name === "AbortError") {
+ toast.error("요청 시간이 초과되었습니다.");
+ throw new Error("Request timeout");
+ }
+ throw error;
+ }
+}
+
+export const apiClient = {
+ get: (url: string, options?: RequestOptions) =>
+ request(url, { ...options, method: "GET" }),
+ post: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "POST", body: body ? JSON.stringify(body) : undefined }),
+ put: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "PUT", body: body ? JSON.stringify(body) : undefined }),
+ delete: (url: string, options?: RequestOptions) =>
+ request(url, { ...options, method: "DELETE" }),
+};
diff --git a/src/lib/api/endpoints/Clubs.ts b/src/lib/api/endpoints/Clubs.ts
new file mode 100644
index 0000000..3a5d995
--- /dev/null
+++ b/src/lib/api/endpoints/Clubs.ts
@@ -0,0 +1,10 @@
+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=
+ myClubs: `${API_BASE_URL}/me/clubs`,
+ recommendations: `${API_BASE_URL}/clubs/recommendations`,
+ search: `${API_BASE_URL}/clubs/search`,
+ join: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/join`,
+} 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/auth.ts b/src/lib/api/endpoints/auth.ts
new file mode 100644
index 0000000..93422a5
--- /dev/null
+++ b/src/lib/api/endpoints/auth.ts
@@ -0,0 +1,12 @@
+import { API_BASE_URL } from "./base";
+
+export const AUTH_ENDPOINTS = {
+ LOGIN: `${API_BASE_URL}/auth/login`,
+ SIGNUP: `${API_BASE_URL}/auth/signup`,
+ EMAIL_VERIFICATION: `${API_BASE_URL}/auth/email-verification`,
+ EMAIL_CONFIRM: `${API_BASE_URL}/auth/email-verification/confirm`,
+ LOGOUT: `${API_BASE_URL}/auth/logout`,
+ ADDITIONAL_INFO: `${API_BASE_URL}/members/additional-info`,
+ CHECK_NICKNAME: `${API_BASE_URL}/members/check-nickname`,
+ PROFILE: `${API_BASE_URL}/members/me`,
+};
diff --git a/src/lib/api/endpoints/base.ts b/src/lib/api/endpoints/base.ts
new file mode 100644
index 0000000..63241dc
--- /dev/null
+++ b/src/lib/api/endpoints/base.ts
@@ -0,0 +1,7 @@
+export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
+
+if (!API_BASE_URL) {
+ console.warn(
+ "Warning: NEXT_PUBLIC_API_URL is not defined in environment variables."
+ );
+}
diff --git a/src/lib/api/endpoints/book.ts b/src/lib/api/endpoints/book.ts
new file mode 100644
index 0000000..a35809c
--- /dev/null
+++ b/src/lib/api/endpoints/book.ts
@@ -0,0 +1,7 @@
+import { API_BASE_URL } from "./base";
+
+export const BOOK_ENDPOINTS = {
+ SEARCH: `${API_BASE_URL}/books/search`,
+ RECOMMEND: `${API_BASE_URL}/books/recommend`,
+ DETAIL: (isbn: string) => `${API_BASE_URL}/books/${isbn}`,
+};
diff --git a/src/lib/api/endpoints/bookstory.ts b/src/lib/api/endpoints/bookstory.ts
new file mode 100644
index 0000000..7f13100
--- /dev/null
+++ b/src/lib/api/endpoints/bookstory.ts
@@ -0,0 +1,5 @@
+import { API_BASE_URL } from "./base";
+
+export const STORY_ENDPOINTS = {
+ LIST: `${API_BASE_URL}/book-stories`,
+};
diff --git a/src/lib/api/endpoints/index.ts b/src/lib/api/endpoints/index.ts
new file mode 100644
index 0000000..cdbbf5f
--- /dev/null
+++ b/src/lib/api/endpoints/index.ts
@@ -0,0 +1,7 @@
+export * from "./base";
+export * from "./auth";
+export * from "./bookstory";
+export * from "./member";
+export * from "./book";
+export * from "./Clubs";
+export * from "./Image";
diff --git a/src/lib/api/endpoints/member.ts b/src/lib/api/endpoints/member.ts
new file mode 100644
index 0000000..6d1a535
--- /dev/null
+++ b/src/lib/api/endpoints/member.ts
@@ -0,0 +1,5 @@
+import { API_BASE_URL } from "./base";
+
+export const MEMBER_ENDPOINTS = {
+ RECOMMEND: `${API_BASE_URL}/members/me/recommend`,
+};
diff --git a/src/lib/api/errors/ApiError.ts b/src/lib/api/errors/ApiError.ts
new file mode 100644
index 0000000..3f3fd12
--- /dev/null
+++ b/src/lib/api/errors/ApiError.ts
@@ -0,0 +1,11 @@
+export class ApiError extends Error {
+ code: string;
+ response?: any;
+
+ constructor(message: string, code: string = "UNKNOWN_ERROR", response?: any) {
+ super(message);
+ this.name = "ApiError";
+ this.code = code;
+ this.response = response;
+ }
+}
diff --git a/src/lib/api/errors/errorMapper.ts b/src/lib/api/errors/errorMapper.ts
new file mode 100644
index 0000000..3b79c67
--- /dev/null
+++ b/src/lib/api/errors/errorMapper.ts
@@ -0,0 +1,19 @@
+export const ERROR_MESSAGES: Record = {
+ // Common Errors
+ COMMON400: "잘못된 요청입니다.",
+ COMMON401: "인증이 필요합니다.",
+ COMMON403: "접근 권한이 없습니다.",
+ COMMON404: "요청한 리소스를 찾을 수 없습니다.",
+ COMMON500: "서버 내부 오류가 발생했습니다.",
+
+ // Auth Errors
+ USER_NOT_FOUND: "존재하지 않는 사용자입니다.",
+ WRONG_PASSWORD: "비밀번호가 일치하지 않습니다.",
+ DUPLICATE_EMAIL: "이미 사용 중인 이메일입니다.",
+ INVALID_TOKEN: "유효하지 않은 토큰입니다.",
+ EXPIRED_TOKEN: "만료된 토큰입니다.",
+};
+
+export function getErrorMessage(code: string): string {
+ return ERROR_MESSAGES[code] || "알 수 없는 오류가 발생했습니다.";
+}
diff --git a/src/lib/api/errors/index.ts b/src/lib/api/errors/index.ts
new file mode 100644
index 0000000..a184b37
--- /dev/null
+++ b/src/lib/api/errors/index.ts
@@ -0,0 +1,2 @@
+export * from "./ApiError";
+export * from "./errorMapper";
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
diff --git a/src/services/bookService.ts b/src/services/bookService.ts
new file mode 100644
index 0000000..c30481d
--- /dev/null
+++ b/src/services/bookService.ts
@@ -0,0 +1,31 @@
+import { apiClient } from "@/lib/api/client";
+import { BOOK_ENDPOINTS } from "@/lib/api/endpoints/book";
+import { ApiResponse } from "@/types/auth";
+import { Book, BookSearchResponse } from "@/types/book";
+
+export const bookService = {
+ searchBooks: async (keyword: string, page: number = 1): Promise => {
+ const response = await apiClient.get>(
+ BOOK_ENDPOINTS.SEARCH,
+ {
+ params: {
+ keyword,
+ page
+ },
+ }
+ );
+ return response.result!;
+ },
+ getRecommendedBooks: async (): Promise => {
+ const response = await apiClient.get>(
+ BOOK_ENDPOINTS.RECOMMEND
+ );
+ return response.result!;
+ },
+ getBookDetail: async (isbn: string): Promise => {
+ const response = await apiClient.get>(
+ BOOK_ENDPOINTS.DETAIL(isbn)
+ );
+ return response.result!;
+ },
+};
diff --git a/src/services/clubService.ts b/src/services/clubService.ts
new file mode 100644
index 0000000..10337e3
--- /dev/null
+++ b/src/services/clubService.ts
@@ -0,0 +1,74 @@
+import { apiClient } from "@/lib/api/client";
+
+// 신욱
+import { CLUB_ENDPOINTS } from "@/lib/api/endpoints/club";
+import type { MyClubListResponse } from "@/types/club";
+import type { ApiResponse as AuthApiResponse } from "@/types/auth";
+
+// 기현
+import { CLUBS } from "@/lib/api/endpoints/Clubs";
+import type { ApiResponse } from "@/lib/api/types";
+import type { CreateClubRequest } from "@/types/groups/clubCreate";
+import type {
+ ClubJoinRequest,
+ ClubJoinResponse,
+ ClubSearchParams,
+ ClubSearchResponse,
+ MyClubsResponse,
+ RecommendationsResponse,
+} from "@/types/groups/clubsearch";
+
+export const clubService = {
+
+ getMyClubs: async (): Promise => {
+ const response = await apiClient.get>(
+ CLUB_ENDPOINTS.MY_CLUBS
+ );
+ return response.result!;
+ },
+
+
+ getMyClubsV2: async () => {
+ const res = await apiClient.get(CLUBS.myClubs);
+ return res.result;
+ },
+
+ // GET /api/clubs/check-name?clubName=...
+ checkNameDuplicate: async (clubName: string) => {
+ const res = await apiClient.get>(CLUBS.checkName, {
+ params: { clubName },
+ });
+ return res.result;
+ },
+
+ // POST /api/clubs
+ createClub: async (payload: CreateClubRequest) => {
+ const res = await apiClient.post>(CLUBS.create, payload);
+ return res.result;
+ },
+
+ // GET /api/clubs/recommendations
+ getRecommendations: async () => {
+ const res = await apiClient.get(CLUBS.recommendations);
+ return res.result;
+ },
+
+ // GET /api/clubs/search
+ searchClubs: async (params: ClubSearchParams) => {
+ const cleaned: any = { ...params };
+ if (cleaned.cursorId == null) delete cleaned.cursorId;
+ if (typeof cleaned.keyword === "string" && cleaned.keyword.trim() === "") delete cleaned.keyword;
+ if (cleaned.inputFilter == null) delete cleaned.inputFilter;
+
+ const res = await apiClient.get(CLUBS.search, {
+ params: cleaned,
+ });
+ return res.result;
+ },
+
+ // POST /api/clubs/{clubId}/join
+ joinClub: async (clubId: number, body: ClubJoinRequest) => {
+ const res = await apiClient.post(CLUBS.join(clubId), body);
+ return res.result;
+ },
+};
\ No newline at end of file
diff --git a/src/services/imageService.ts b/src/services/imageService.ts
new file mode 100644
index 0000000..4301f11
--- /dev/null
+++ b/src/services/imageService.ts
@@ -0,0 +1,48 @@
+// src/services/imageService.ts
+import { apiClient } from "@/lib/api/client";
+import type { ApiResponse } from "@/lib/api/types";
+import { IMAGE, type ImageUploadType } from "@/lib/api/endpoints/Image";
+
+type PresignedRequest = {
+ originalFileName: string;
+ contentType: string;
+};
+
+type PresignedResult = {
+ presignedUrl: string;
+ imageUrl: string;
+};
+
+async function putToPresignedUrl(presignedUrl: string, file: File, contentType: string) {
+ const res = await fetch(presignedUrl, {
+ method: "PUT",
+ headers: { "Content-Type": contentType },
+ body: file,
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`S3 업로드 실패 (HTTP ${res.status}) ${text}`);
+ }
+}
+
+export const imageService = {
+ // POST /api/image/{type}/upload-url
+ getUploadUrl: (type: ImageUploadType, body: PresignedRequest) =>
+ apiClient.post>(IMAGE.uploadUrl(type), body),
+
+ // ✅ create 페이지에서 쓰는 "CLUB 업로드 한방"
+ uploadClubImage: async (file: File) => {
+ const contentType = file.type || "application/octet-stream";
+
+ const presigned = await imageService.getUploadUrl("CLUB", {
+ originalFileName: file.name,
+ contentType,
+ });
+
+ const { presignedUrl, imageUrl } = presigned.result;
+
+ await putToPresignedUrl(presignedUrl, file, contentType);
+ return imageUrl;
+ },
+};
\ No newline at end of file
diff --git a/src/services/memberService.ts b/src/services/memberService.ts
new file mode 100644
index 0000000..fa4a4fa
--- /dev/null
+++ b/src/services/memberService.ts
@@ -0,0 +1,13 @@
+import { apiClient } from "@/lib/api/client";
+import { MEMBER_ENDPOINTS } from "@/lib/api/endpoints/member";
+import { RecommendResponse } from "@/types/member";
+import { ApiResponse } from "@/types/auth";
+
+export const memberService = {
+ getRecommendedMembers: async (): Promise => {
+ const response = await apiClient.get>(
+ MEMBER_ENDPOINTS.RECOMMEND
+ );
+ return response.result!;
+ },
+};
diff --git a/src/services/storyService.ts b/src/services/storyService.ts
new file mode 100644
index 0000000..9ff936e
--- /dev/null
+++ b/src/services/storyService.ts
@@ -0,0 +1,63 @@
+import { apiClient } from "@/lib/api/client";
+import { STORY_ENDPOINTS } from "@/lib/api/endpoints/bookstory";
+import { BookStoryListResponse, BookStoryDetail, CreateBookStoryRequest } from "@/types/story";
+import { ApiResponse } from "@/types/auth";
+
+export const storyService = {
+ getAllStories: async (cursorId?: number): Promise => {
+ const response = await apiClient.get>(
+ STORY_ENDPOINTS.LIST,
+ {
+ params: { cursorId },
+ }
+ );
+ return response.result!;
+ },
+ getStoryById: async (id: number): Promise => {
+ const response = await apiClient.get>(
+ `${STORY_ENDPOINTS.LIST}/${id}`
+ );
+ return response.result!;
+ },
+ createBookStory: async (data: CreateBookStoryRequest): Promise => {
+ const response = await apiClient.post>(
+ STORY_ENDPOINTS.LIST,
+ data
+ );
+ return response.result!;
+ },
+ createComment: async (
+ bookStoryId: number,
+ data: { content: string },
+ parentCommentId?: number
+ ): Promise => {
+ const response = await apiClient.post>(
+ `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments`,
+ data,
+ {
+ params: { parentCommentId }
+ }
+ );
+ return response.result!;
+ },
+ updateComment: async (
+ bookStoryId: number,
+ commentId: number,
+ data: { content: string }
+ ): Promise => {
+ const response = await apiClient.patch>(
+ `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments/${commentId}`,
+ data
+ );
+ return response.result!;
+ },
+ deleteComment: async (
+ bookStoryId: number,
+ commentId: number
+ ): Promise => {
+ const response = await apiClient.delete>(
+ `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments/${commentId}`
+ );
+ return response.result!;
+ },
+};
diff --git a/src/types/book.ts b/src/types/book.ts
new file mode 100644
index 0000000..11b992f
--- /dev/null
+++ b/src/types/book.ts
@@ -0,0 +1,15 @@
+export interface Book {
+ isbn: string;
+ title: string;
+ author: string;
+ imgUrl: string;
+ publisher: string;
+ description: string;
+ link: string;
+}
+
+export interface BookSearchResponse {
+ detailInfoList: Book[];
+ hasNext: boolean;
+ currentPage: number;
+}
diff --git a/src/types/groups/bookcasedetail.ts b/src/types/groups/bookcasedetail.ts
new file mode 100644
index 0000000..7c75b2e
--- /dev/null
+++ b/src/types/groups/bookcasedetail.ts
@@ -0,0 +1,45 @@
+export type MemberInfo = {
+ nickname: string;
+ profileImageUrl: string | null;
+};
+
+export type TeamMember = {
+ // TODO(BE 연동): GET 응답에 clubMemberId가 포함되어야 함.
+ clubMemberId: number;
+ memberInfo: MemberInfo;
+ teamNumber: number | null;
+};
+
+export type GetMeetingTeamsResult = {
+ existingTeamNumbers: number[];
+ members: TeamMember[];
+ hasNext: boolean;
+ nextCursor: string | null;
+};
+
+export type TeamMemberListPutBody = {
+ teamMemberList: {
+ teamNumber: number;
+ clubMemberIds: number[];
+ }[];
+};
+
+export const MAX_TEAMS = 7;
+
+export function teamLabel(teamNumber: number) {
+ // 1 -> A, 2 -> B ...
+ const code = 64 + teamNumber;
+ if (code < 65 || code > 90) return `${teamNumber}`;
+ return String.fromCharCode(code);
+}
+
+export function normalizeTeams(input: number[]) {
+ // 팀 번호가 이상하게 들어와도 1..N 형태로 정규화
+ const uniqueSorted = Array.from(new Set(input))
+ .filter((n) => Number.isFinite(n) && n > 0)
+ .sort((a, b) => a - b);
+
+ if (uniqueSorted.length === 0) return [1];
+ // 1..N이 아닐 수도 있으니 갯수만큼 1..len
+ return uniqueSorted.map((_, idx) => idx + 1);
+}
diff --git a/src/types/groups/bookcasehome.ts b/src/types/groups/bookcasehome.ts
new file mode 100644
index 0000000..e24ddd1
--- /dev/null
+++ b/src/types/groups/bookcasehome.ts
@@ -0,0 +1,93 @@
+// src/types/bookcase.ts
+const DEFAULT_BOOK_COVER = "/dummy_book_cover.png";
+/** ===== API Raw Types ===== */
+export type BookcaseApiResponse = {
+ isSuccess: boolean;
+ code: string;
+ message: string;
+ result: BookcaseApiResult;
+};
+
+export type BookcaseApiResult = {
+ bookShelfInfoList: BookShelfInfo[];
+ hasNext: boolean;
+ nextCursor: string | null;
+};
+
+export type BookShelfInfo = {
+ meetingInfo: MeetingInfo;
+ bookInfo: BookInfo;
+};
+
+export type MeetingInfo = {
+ meetingId: number;
+ generation: number; // 1,2,3...
+ tag: "MEETING" | string; // 서버가 더 늘리면 string으로 대응
+ averageRate: number; // 평점(0~5 같은 값일 가능성)
+};
+
+export type BookInfo = {
+ bookId: string; // ISBN 같은 문자열
+ title: string;
+ author: string;
+ imgUrl: string | null;
+};
+
+/** ===== UI View Model Types (BookcaseCard에 꽂기 좋은 형태) ===== */
+export type BookcaseCardCategory = {
+ generation: string; // "1기" 같은 라벨
+ genre: string; // 서버에 장르 없으니 tag 등으로 대체
+};
+
+export type BookcaseCardModel = {
+ bookId: string;
+ title: string;
+ author: string;
+ imageUrl: string;
+ category: BookcaseCardCategory;
+ rating: number;
+ meetingId: number;
+ generationNumber: number;
+ tag: string;
+};
+
+export type BookcaseSectionModel = {
+ generationNumber: number;
+ generationLabel: string; // "1기"
+ books: BookcaseCardModel[];
+};
+
+/** ===== Mapper / Grouping Utils ===== */
+export const toBookcaseCardModel = (item: BookShelfInfo): BookcaseCardModel => ({
+ bookId: item.bookInfo.bookId,
+ title: item.bookInfo.title,
+ author: item.bookInfo.author,
+ imageUrl: item.bookInfo.imgUrl ?? DEFAULT_BOOK_COVER,
+ category: {
+ generation: `${item.meetingInfo.generation}기`,
+ genre: item.meetingInfo.tag, // 서버에 genre 없으니 tag를 박아둠
+ },
+ rating: item.meetingInfo.averageRate ?? 0,
+ meetingId: item.meetingInfo.meetingId,
+ generationNumber: item.meetingInfo.generation,
+ tag: item.meetingInfo.tag,
+});
+
+export const groupByGeneration = (list: BookShelfInfo[]): BookcaseSectionModel[] => {
+ const map = new Map();
+
+ for (const item of list) {
+ const gen = item.meetingInfo.generation;
+ const arr = map.get(gen) ?? [];
+ arr.push(toBookcaseCardModel(item));
+ map.set(gen, arr);
+ }
+
+ return Array.from(map.entries())
+ .sort((a, b) => b[0] - a[0]) // 최신 기수 먼저 (원하면 반대로)
+ .map(([generationNumber, books]) => ({
+ generationNumber,
+ generationLabel: `${generationNumber}기`,
+ books,
+ }));
+};
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
diff --git a/src/types/groups/clubsearch.ts b/src/types/groups/clubsearch.ts
new file mode 100644
index 0000000..7717e31
--- /dev/null
+++ b/src/types/groups/clubsearch.ts
@@ -0,0 +1,98 @@
+import { ApiResponse } from "@/lib/api/types";
+
+export type MyClubItem = {
+ clubId: number;
+ clubName: string;
+};
+
+export type MyClubsResult = {
+ clubList: MyClubItem[];
+};
+
+export type MyClubsResponse = ApiResponse;
+
+// ===== 2) 모임 추천: GET /api/clubs/recommendations =====
+export type ClubCategoryDTO = {
+ code: string;
+ description: string;
+};
+
+export type ParticipantTypeDTO = {
+ code: string; // ex) STUDENT, WORKER...
+ description: string;
+};
+
+export type ClubLinkDTO = {
+ link: string;
+ label: string;
+};
+
+export type ClubDTO = {
+ clubId: number;
+ name: string;
+ description: string;
+ profileImageUrl: string | null;
+ region: string;
+ category: ClubCategoryDTO[];
+ participantTypes: ParticipantTypeDTO[];
+ links: ClubLinkDTO[];
+ open: boolean;
+};
+
+export type ClubInfoDTO = {
+ club: ClubDTO;
+ myStatus: string; // NONE, REQUESTED, MEMBER ... (백엔드 enum 확정 전)
+};
+
+export type RecommendationItemDTO = {
+ rank: number;
+ clubInfo: ClubInfoDTO;
+ overlapCount: number;
+ activeMemberCount: number;
+ lastActivityAt: string;
+};
+
+export type RecommendationsResult = {
+ recommendations: RecommendationItemDTO[];
+};
+
+export type RecommendationsResponse = ApiResponse;
+
+// ===== 3) 모임 검색: GET /api/clubs/search =====
+export type InputFilter = "NAME" | "REGION";
+
+export type OutputFilter =
+ | "ALL"
+ | "STUDENT"
+ | "WORKER"
+ | "ONLINE"
+ | "CLUB"
+ | "MEETING"
+ | "OFFLINE";
+
+export type ClubListItemDTO = {
+ club: ClubDTO;
+ myStatus: string;
+};
+
+export type ClubSearchResult = {
+ clubList: ClubListItemDTO[];
+ hasNext: boolean;
+ nextCursor: number | null;
+};
+
+export type ClubSearchResponse = ApiResponse;
+
+export type ClubSearchParams = {
+ keyword?: string;
+ inputFilter?: InputFilter | null;
+ outputFilter: OutputFilter;
+ cursorId?: number | null;
+};
+
+// ===== 4) 모임 가입 신청: POST /api/clubs/{clubId}/join =====
+export type ClubJoinRequest = {
+ joinMessage: string;
+};
+
+export type ClubJoinResponse = ApiResponse;
\ No newline at end of file
diff --git a/src/types/member.ts b/src/types/member.ts
new file mode 100644
index 0000000..bb76c9f
--- /dev/null
+++ b/src/types/member.ts
@@ -0,0 +1,8 @@
+export interface RecommendedMember {
+ nickname: string;
+ profileImageUrl: string;
+}
+
+export interface RecommendResponse {
+ friends: RecommendedMember[];
+}
diff --git a/src/types/story.ts b/src/types/story.ts
new file mode 100644
index 0000000..402d779
--- /dev/null
+++ b/src/types/story.ts
@@ -0,0 +1,88 @@
+export interface BookStory {
+ bookStoryId: number;
+ bookInfo: {
+ bookId: number;
+ title: string;
+ author: string;
+ imgUrl: string;
+ };
+ authorInfo: {
+ nickname: string;
+ profileImageUrl: string;
+ following: boolean;
+ };
+ bookStoryTitle: string;
+ description: string;
+ likes: number;
+ commentCount: number;
+ viewCount: number;
+ likedByMe: boolean;
+ createdAt: string;
+ writtenByMe: boolean;
+}
+
+export interface BookStoryListResponse {
+ basicInfoList: BookStory[];
+ hasNext: boolean;
+ nextCursor: number | null;
+ pageSize: number;
+}
+
+
+export interface BookInfo {
+ bookId: string;
+ title: string;
+ author: string;
+ imgUrl: string;
+}
+
+export interface AuthorInfo {
+ nickname: string;
+ profileImageUrl: string;
+ following: boolean;
+}
+
+export interface CommentInfo {
+ commentId: number;
+ content: string;
+ authorInfo: AuthorInfo;
+ createdAt: string;
+ writtenByMe: boolean;
+ deleted: boolean;
+ parentCommentId?: number | null;
+}
+
+
+export interface BookStoryDetail {
+ bookStoryId: number;
+ bookInfo: BookInfo;
+ authorInfo: AuthorInfo;
+ bookStoryTitle: string;
+ description: string;
+ likes: number;
+ likedByMe: boolean;
+ createdAt: string;
+ writtenByMe: boolean;
+ viewCount: number;
+ commentCount: number;
+ comments: CommentInfo[];
+ prevBookStoryId: number;
+ nextBookStoryId: number;
+}
+
+export interface CreateBookStoryRequest {
+ bookInfo: {
+ isbn: string;
+ title: string;
+ author: string;
+ imgUrl: string;
+ publisher: string;
+ description: string;
+ };
+ title: string;
+ description: string;
+}
+
+export interface CreateCommentRequest {
+ content: string;
+}
diff --git a/src/utils/groupMapper.ts b/src/utils/groupMapper.ts
index 73e0e09..2a12cca 100644
--- a/src/utils/groupMapper.ts
+++ b/src/utils/groupMapper.ts
@@ -1,4 +1,5 @@
-import { Category, ParticipantType } from "@/app/groups/page";
+import { Category, ParticipantType } from "@/types/groups/groups";
+
/** UI 카테고리 → API participantType (전체는 필터 없음이라 null) */
diff --git a/src/utils/time.ts b/src/utils/time.ts
new file mode 100644
index 0000000..1cd005f
--- /dev/null
+++ b/src/utils/time.ts
@@ -0,0 +1,35 @@
+export function formatTimeAgo(dateInput: Date | string): string {
+ let parsedInput = dateInput;
+ if (typeof dateInput === 'string') {
+ parsedInput = dateInput.replace(" ", "T");
+ }
+ const targetDate = new Date(parsedInput);
+ const now = new Date();
+
+ if (isNaN(targetDate.getTime())) {
+ return "";
+ }
+
+ const diffMs = now.getTime() - targetDate.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays > 30) {
+ const year = targetDate.getFullYear();
+ const month = String(targetDate.getMonth() + 1).padStart(2, "0");
+ const day = String(targetDate.getDate()).padStart(2, "0");
+ return `${year}.${month}.${day}.`;
+ }
+
+ if (diffDays > 0) {
+ return `${diffDays}일 전`;
+ }
+ if (diffHours > 0) {
+ return `${diffHours}시간 전`;
+ }
+ if (diffMins > 0) {
+ return `${diffMins}분 전`;
+ }
+ return "방금 전";
+}
diff --git a/src/utils/url.ts b/src/utils/url.ts
new file mode 100644
index 0000000..b27b404
--- /dev/null
+++ b/src/utils/url.ts
@@ -0,0 +1,25 @@
+/**
+ * URL 유효성 검사 (Swagger 기본값 "string" 또는 빈 값 처리)
+ */
+export const isValidUrl = (url: string | null | undefined): boolean => {
+ if (!url || url === "string" || url.trim() === "") return false;
+
+ // 허용되는 상대 경로 패턴 (예: /profile2.svg)
+ if (url.startsWith("/")) return true;
+
+ try {
+ new URL(url);
+ return true; // http, https 등 유효한 scheme이 있는 경우
+ } catch {
+ // 프로토콜 상대 URL 지원 (예: //example.com/image.png)
+ if (url.startsWith("//")) {
+ try {
+ new URL(`https:${url}`);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+ }
+};