diff --git a/src/api/books/getBookDetail.ts b/src/api/books/getBookDetail.ts new file mode 100644 index 00000000..44b6d86b --- /dev/null +++ b/src/api/books/getBookDetail.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 책 상세 정보 타입 +export interface BookDetail { + title: string; + imageUrl: string; + authorName: string; + publisher: string; + isbn: string; + description: string; + recruitingRoomCount: number; + readCount: number; + isSaved: boolean; +} + +// API 응답 타입 +export interface BookDetailResponse { + isSuccess: boolean; + code: number; + message: string; + data: BookDetail; +} + +export const getBookDetail = async (isbn: string): Promise => { + try { + const response = await apiClient.get(`/books/${isbn}`); + + return response.data; + } catch (error) { + console.error('책 상세 정보 API 오류:', error); + throw error; + } +}; diff --git a/src/api/books/getMostSearchedBooks.ts b/src/api/books/getMostSearchedBooks.ts new file mode 100644 index 00000000..fbdbcf3e --- /dev/null +++ b/src/api/books/getMostSearchedBooks.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../index'; + +// 인기 검색 도서 타입 +export interface MostSearchedBook { + rank: number; + title: string; + imageUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface MostSearchedBooksData { + bookList: MostSearchedBook[]; +} + +// API 응답 타입 +export interface MostSearchedBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: MostSearchedBooksData; +} + +export const getMostSearchedBooks = async (): Promise => { + try { + const response = await apiClient.get('/books/most-searched'); + return response.data; + } catch (error) { + console.error('인기 검색 도서 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/books/getRecruitingRooms.ts b/src/api/books/getRecruitingRooms.ts new file mode 100644 index 00000000..f83eb397 --- /dev/null +++ b/src/api/books/getRecruitingRooms.ts @@ -0,0 +1,40 @@ +import { apiClient } from '../index'; + +// 모집중인 모임방 타입 +export interface RecruitingRoom { + roomId: number; + bookImageUrl: string; + roomName: string; + memberCount: number; + recruitCount: number; + deadlineEndDate: string; +} + +// API 응답 데이터 타입 +export interface RecruitingRoomsData { + recruitingRoomList: RecruitingRoom[]; + totalRoomCount: number; + nextCursor: string; + isLast: boolean; +} + +// API 응답 타입 +export interface RecruitingRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: RecruitingRoomsData; +} + +export const getRecruitingRooms = async (isbn: string): Promise => { + try { + const response = await apiClient.get( + `/books/${isbn}/recruiting-rooms`, + ); + + return response.data; + } catch (error) { + console.error('모집중인 모임방 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/books/getSavedBooks.ts b/src/api/books/getSavedBooks.ts new file mode 100644 index 00000000..8157a8b8 --- /dev/null +++ b/src/api/books/getSavedBooks.ts @@ -0,0 +1,39 @@ +import { apiClient } from '../index'; + +// 저장한 책 정보 타입 +export interface SavedBook { + bookId: number; + bookTitle: string; + authorName: string; + publisher: string; + bookImageUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface SavedBooksData { + bookList: SavedBook[]; +} + +// API 응답 타입 +export interface SavedBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: SavedBooksData; +} + +// 저장한 책 또는 참여 중 모임의 책 조회 +export const getSavedBooks = async (type: 'saved' | 'joining'): Promise => { + try { + const response = await apiClient.get('/books/selectable-list', { + params: { + type: type.toUpperCase(), + }, + }); + return response.data; + } catch (error) { + console.error('저장한 책 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/books/getSearchBooks.ts b/src/api/books/getSearchBooks.ts new file mode 100644 index 00000000..6f3add94 --- /dev/null +++ b/src/api/books/getSearchBooks.ts @@ -0,0 +1,70 @@ +import { apiClient } from '../index'; + +// 검색된 책 타입 (API 응답에서 받는 형태) +export interface BookSearchItem { + title: string; + imageUrl: string; + authorName: string; + publisher: string; + isbn: string; +} + +// 검색된 책 타입 (컴포넌트에서 사용하는 형태) +export interface SearchedBook { + id: number; + title: string; + author: string; + publisher: string; + coverUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface SearchBooksData { + searchResult: BookSearchItem[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; +} + +// API 응답 타입 +export interface SearchBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: SearchBooksData; +} + +export const getSearchBooks = async ( + query: string, + page: number = 1, + isFinalized: boolean = false, +): Promise => { + try { + const response = await apiClient.get('/books', { + params: { + keyword: query.trim(), + page: page, + isFinalized: isFinalized, + }, + }); + return response.data; + } catch (error) { + console.error('책 검색 API 오류:', error); + throw error; + } +}; + +export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { + return apiBooks.map((book, index) => ({ + id: index + 1, + title: book.title, + author: book.authorName, + publisher: book.publisher, + coverUrl: book.imageUrl, + isbn: book.isbn, + })); +}; diff --git a/src/api/books/postSaveBook.ts b/src/api/books/postSaveBook.ts new file mode 100644 index 00000000..8822e4d3 --- /dev/null +++ b/src/api/books/postSaveBook.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../index'; + +// 북마크 요청 타입 +export interface SaveBookRequest { + type: boolean; +} + +// 북마크 응답 데이터 타입 +export interface SaveBookData { + isbn: string; + isSaved: boolean; +} + +// 북마크 응답 타입 +export interface SaveBookResponse { + isSuccess: boolean; + code: number; + message: string; + data: SaveBookData; +} + +export const postSaveBook = async (isbn: string, type: boolean): Promise => { + try { + const response = await apiClient.post(`/books/${isbn}/saved`, { + type: type, + }); + return response.data; + } catch (error) { + console.error('책 저장 API 오류:', error); + throw error; + } +}; diff --git a/src/api/feeds/getWriteInfo.ts b/src/api/feeds/getWriteInfo.ts new file mode 100644 index 00000000..8e4d3ec8 --- /dev/null +++ b/src/api/feeds/getWriteInfo.ts @@ -0,0 +1,38 @@ +import { apiClient } from '../index'; + +// 카테고리 및 태그 데이터 타입 +export interface CategoryData { + category: string; + tagList: string[]; +} + +// API 응답 데이터 타입 +export interface WriteInfoData { + categoryList: CategoryData[]; +} + +// API 응답 타입 +export interface GetWriteInfoResponse { + isSuccess: boolean; + code: number; + message: string; + data: WriteInfoData; +} + +// 새 글 작성을 위한 카테고리 및 태그 조회 API 함수 +export const getWriteInfo = async () => { + const response = await apiClient.get('/feeds/write-info'); + return response.data; +}; + +/* +사용 예시: +const writeInfo = await getWriteInfo(); +console.log(writeInfo.data.categoryList); // CategoryData[] + +// 카테고리별 태그 접근 +writeInfo.data.categoryList.forEach(category => { + console.log(`카테고리: ${category.category}`); + console.log(`태그: ${category.tagList.join(', ')}`); +}); +*/ diff --git a/src/api/index.ts b/src/api/index.ts index 68563e02..60a1b703 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -13,7 +13,33 @@ export const apiClient = axios.create({ withCredentials: true, // 쿠키 자동 전송 설정 }); -// 응답 인터셉터 (에러 처리) +// 임시 하드코딩된 토큰 (쿠키가 없을 때 사용) +const TEMP_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.BSGuoMWlrzc0oKgSJXHEycxdzzY9-e7gD4xh-wSDemc'; + +// Request 인터셉터: 쿠키가 없을 때 임시 토큰을 헤더에 추가 +apiClient.interceptors.request.use( + config => { + // 쿠키에서 Authorization 확인 + const cookies = document.cookie.split(';'); + const hasAuthCookie = cookies.some(cookie => cookie.trim().startsWith('Authorization=')); + + // 쿠키가 없으면 임시 토큰을 헤더에 추가 + if (!hasAuthCookie) { + console.log('🔑 쿠키가 없어서 임시 토큰을 헤더에 추가합니다.'); + config.headers.Authorization = `Bearer ${TEMP_ACCESS_TOKEN}`; + } else { + console.log('✅ Authorization 쿠키가 있어서 자동으로 전송됩니다.'); + } + + return config; + }, + error => { + return Promise.reject(error); + }, +); + +// Response 인터셉터: 401 에러 시 로그인 페이지로 리다이렉트 apiClient.interceptors.response.use( (response: AxiosResponse) => response, (error: AxiosError) => { diff --git a/src/api/memory/getMemoryPosts.ts b/src/api/memory/getMemoryPosts.ts new file mode 100644 index 00000000..acd7f374 --- /dev/null +++ b/src/api/memory/getMemoryPosts.ts @@ -0,0 +1,99 @@ +import { apiClient } from '../index'; +import type { GetMemoryPostsParams, GetMemoryPostsResponse } from '@/types/memory'; + +// 기록장 조회 API 함수 +export const getMemoryPosts = async ( + params: GetMemoryPostsParams, +): Promise => { + const { roomId, ...queryParams } = params; + + // 쿼리 파라미터 생성 + const searchParams = new URLSearchParams(); + + // 기본값 적용 + searchParams.append('type', queryParams.type || 'group'); + + // type이 group인 경우만 sort 파라미터 추가 + if ((queryParams.type || 'group') === 'group' && queryParams.sort) { + searchParams.append('sort', queryParams.sort); + } + + // 페이지 필터 파라미터 + if (queryParams.pageStart !== undefined && queryParams.pageStart !== null) { + searchParams.append('pageStart', queryParams.pageStart.toString()); + } + if (queryParams.pageEnd !== undefined && queryParams.pageEnd !== null) { + searchParams.append('pageEnd', queryParams.pageEnd.toString()); + } + + // 필터 파라미터 + if (queryParams.isOverview !== undefined) { + searchParams.append('isOverview', queryParams.isOverview.toString()); + } + if (queryParams.isPageFilter !== undefined) { + searchParams.append('isPageFilter', queryParams.isPageFilter.toString()); + } + + // 커서 파라미터 + if (queryParams.cursor) { + searchParams.append('cursor', queryParams.cursor); + } + + const url = `/rooms/${roomId}/posts?${searchParams.toString()}`; + + try { + const response = await apiClient.get(url); + return response.data; + } catch (error) { + console.error('기록장 조회 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: + +// 그룹 기록 전체 조회 (기본) +const groupPosts = await getMemoryPosts({ + roomId: 1 +}); + +// 내 기록만 조회 +const myPosts = await getMemoryPosts({ + roomId: 1, + type: 'mine' +}); + +// 그룹 기록 인기순 정렬 +const popularPosts = await getMemoryPosts({ + roomId: 1, + type: 'group', + sort: 'like' +}); + +// 페이지 필터 적용 (10-20페이지) +const pagePosts = await getMemoryPosts({ + roomId: 1, + type: 'group', + pageStart: 10, + pageEnd: 20, + isPageFilter: true +}); + +// 총평 보기 필터 +const overviewPosts = await getMemoryPosts({ + roomId: 1, + type: 'group', + isOverview: true +}); + +// 페이지네이션 (다음 페이지) +const nextPagePosts = await getMemoryPosts({ + roomId: 1, + cursor: 'some-cursor-value' +}); + +console.log('Posts:', groupPosts.data.postList); +console.log('Next cursor:', groupPosts.data.nextCursor); +console.log('Is last page:', groupPosts.data.isLast); +*/ diff --git a/src/api/recentsearch/deleteRecentSearch.ts b/src/api/recentsearch/deleteRecentSearch.ts new file mode 100644 index 00000000..7983b4b9 --- /dev/null +++ b/src/api/recentsearch/deleteRecentSearch.ts @@ -0,0 +1,24 @@ +import { apiClient } from '../index'; + +// 최근 검색어 삭제 응답 타입 +export interface DeleteRecentSearchResponse { + isSuccess: boolean; + code: number; + message: string; + data: string; +} + +export const deleteRecentSearch = async ( + recentSearchId: number, + userId: number, +): Promise => { + try { + const response = await apiClient.delete( + `/recent-searches/${recentSearchId}?userId=${userId}`, + ); + return response.data; + } catch (error) { + console.error('최근 검색어 삭제 API 오류:', error); + throw error; + } +}; diff --git a/src/api/recentsearch/getRecentSearch.ts b/src/api/recentsearch/getRecentSearch.ts index 3f156880..2d3a9670 100644 --- a/src/api/recentsearch/getRecentSearch.ts +++ b/src/api/recentsearch/getRecentSearch.ts @@ -19,15 +19,7 @@ export interface GetRecentSearchResponse { }; } -// 최근 검색어 조회 API 함수 export const getRecentSearch = async (type: SearchType) => { - const response = await apiClient.get(`/recent-search?type=${type}`); + const response = await apiClient.get(`/recent-searches?type=${type}`); return response.data; }; - -/* -// 사용 예시 -const recentUserSearches = await getRecentSearch('USER'); -const recentRoomSearches = await getRecentSearch('ROOM'); -const recentBookSearches = await getRecentSearch('BOOK'); -*/ diff --git a/src/api/record/createRecord.ts b/src/api/record/createRecord.ts new file mode 100644 index 00000000..4d8cdc46 --- /dev/null +++ b/src/api/record/createRecord.ts @@ -0,0 +1,38 @@ +import { apiClient } from '../index'; +import type { CreateRecordRequest, CreateRecordData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type CreateRecordResponse = ApiResponse; + +// 기록 생성 API 함수 +export const createRecord = async (roomId: number, recordData: CreateRecordRequest) => { + const response = await apiClient.post( + `/rooms/${roomId}/record`, + recordData, + ); + return response.data; +}; + +/* +사용 예시: +const recordData: CreateRecordRequest = { + page: 20, + isOverview: false, + content: "맘은 최고의 새버린다." +}; + +try { + const result = await createRecord(1, recordData); + if (result.isSuccess) { + console.log("생성된 기록 ID:", result.data.recordId); + console.log("방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("기록 생성 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/record/createVote.ts b/src/api/record/createVote.ts new file mode 100644 index 00000000..ebaaec73 --- /dev/null +++ b/src/api/record/createVote.ts @@ -0,0 +1,39 @@ +import { apiClient } from '../index'; +import type { CreateVoteRequest, CreateVoteData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type CreateVoteResponse = ApiResponse; + +// 투표 생성 API 함수 +export const createVote = async (roomId: number, voteData: CreateVoteRequest) => { + const response = await apiClient.post(`/rooms/${roomId}/vote`, voteData); + return response.data; +}; + +/* +사용 예시: +const voteData: CreateVoteRequest = { + page: 20, + isOverview: true, + content: "맘은 최고의 새버린다. 셰익스피어의?", + voteItemList: [ + { itemName: "네" }, + { itemName: "아니오" } + ] +}; + +try { + const result = await createVote(1, voteData); + if (result.isSuccess) { + console.log("생성된 투표 ID:", result.data.voteId); + console.log("방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("투표 생성 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/record/deleteRecord.ts b/src/api/record/deleteRecord.ts new file mode 100644 index 00000000..5c8f5502 --- /dev/null +++ b/src/api/record/deleteRecord.ts @@ -0,0 +1,43 @@ +import { apiClient } from '../index'; +import type { ApiResponse } from '@/types/record'; + +// 기록 삭제 응답 데이터 타입 +export interface DeleteRecordData { + roomId: number; // 삭제된 기록이 속한 방 ID +} + +// API 응답 타입 +export type DeleteRecordResponse = ApiResponse; + +// 기록 삭제 API 함수 +export const deleteRecord = async ( + roomId: number, + recordId: number, +): Promise => { + try { + const response = await apiClient.delete( + `/rooms/${roomId}/record/${recordId}`, + ); + return response.data; + } catch (error) { + console.error('기록 삭제 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await deleteRecord(1, 123); + if (result.isSuccess) { + console.log("기록 삭제 성공:", result.data.roomId); + // 성공 처리 로직 (예: 목록에서 제거, 성공 메시지 표시) + } else { + console.error("기록 삭제 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/record/deleteVote.ts b/src/api/record/deleteVote.ts new file mode 100644 index 00000000..9ffd91c8 --- /dev/null +++ b/src/api/record/deleteVote.ts @@ -0,0 +1,38 @@ +import { apiClient } from '../index'; +import type { ApiResponse } from '@/types/record'; + +// 투표 삭제 응답 데이터 타입 +export interface DeleteVoteData { + roomId: number; // 삭제된 투표가 속한 방 ID +} + +// API 응답 타입 +export type DeleteVoteResponse = ApiResponse; + +// 투표 삭제 API 함수 +export const deleteVote = async (roomId: number, voteId: number): Promise => { + try { + const response = await apiClient.delete(`/rooms/${roomId}/vote/${voteId}`); + return response.data; + } catch (error) { + console.error('투표 삭제 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await deleteVote(1, 456); + if (result.isSuccess) { + console.log("투표 삭제 성공:", result.data.roomId); + // 성공 처리 로직 (예: 목록에서 제거, 성공 메시지 표시) + } else { + console.error("투표 삭제 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/rooms/createDailyGreeting.ts b/src/api/rooms/createDailyGreeting.ts new file mode 100644 index 00000000..ebc697c3 --- /dev/null +++ b/src/api/rooms/createDailyGreeting.ts @@ -0,0 +1,58 @@ +import { apiClient } from '../index'; + +// 오늘의 한마디 작성 요청 데이터 타입 +export interface CreateDailyGreetingRequest { + content: string; // 오늘의 한마디 작성 내용 +} + +// 오늘의 한마디 작성 응답 데이터 타입 +export interface CreateDailyGreetingData { + attendanceCheckId: number; // 출석체크 ID +} + +// API 응답 타입 +export interface CreateDailyGreetingResponse { + isSuccess: boolean; + code: number; + message: string; + data: CreateDailyGreetingData; +} + +// 오늘의 한마디 작성 API 함수 +export const createDailyGreeting = async ( + roomId: number, + content: string, +): Promise => { + try { + const requestBody: CreateDailyGreetingRequest = { + content, + }; + + const response = await apiClient.post( + `/rooms/${roomId}/daily-greeting`, + requestBody, + ); + + return response.data; + } catch (error) { + console.error('오늘의 한마디 작성 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await createDailyGreeting(1, "오늘도 좋은 하루 보내세요!"); + if (result.isSuccess) { + console.log("오늘의 한마디 작성 성공:", result.data.attendanceCheckId); + // 성공 처리 로직 (예: 성공 메시지 표시, 페이지 새로고침 등) + } else { + console.error("오늘의 한마디 작성 실패:", result.message); + // 실패 처리 로직 (예: 에러 메시지 표시) + } +} catch (error) { + console.error("API 호출 오류:", error); + // 네트워크 에러 처리 로직 +} +*/ diff --git a/src/api/rooms/getJoinedRooms.ts b/src/api/rooms/getJoinedRooms.ts new file mode 100644 index 00000000..fb104d1b --- /dev/null +++ b/src/api/rooms/getJoinedRooms.ts @@ -0,0 +1,34 @@ +import { apiClient } from '../index'; + +// 가입한 방 목록 응답 데이터 타입 +export interface JoinedRoomItem { + roomId: number; + bookImageUrl: string; + roomTitle: string; + memberCount: number; + userPercentage: number; +} + +export interface JoinedRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomList: JoinedRoomItem[]; + nickname: string; + page: number; + size: number; + last: boolean; + first: boolean; + }; +} + +export const getJoinedRooms = async (page: number = 1): Promise => { + try { + const response = await apiClient.get(`/rooms/home/joined?page=${page}`); + return response.data; + } catch (error) { + console.error('가입한 방 목록 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/rooms/getMyRooms.ts b/src/api/rooms/getMyRooms.ts new file mode 100644 index 00000000..445f0627 --- /dev/null +++ b/src/api/rooms/getMyRooms.ts @@ -0,0 +1,44 @@ +import { apiClient } from '../index'; + +export type RoomType = 'playingAndRecruiting' | 'recruiting' | 'playing' | 'expired'; + +// 방 데이터 타입 +export interface Room { + roomId: number; + bookImageUrl: string; + roomName: string; + recruitCount: number; + memberCount: number; + endDate: string; + type: string; +} + +// 내 방 조회 응답 타입 +export interface MyRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomList: Room[]; + nextCursor: string; + isLast: boolean; + }; +} + +export const getMyRooms = async ( + type: RoomType = 'playingAndRecruiting', + cursor: string | null = null, +): Promise => { + try { + const params = new URLSearchParams(); + params.append('type', type); + if (cursor) { + params.append('cursor', cursor); + } + const response = await apiClient.get(`/rooms/my?${params.toString()}`); + return response.data; + } catch (error) { + console.error('내 방 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/rooms/getRoomDetail.ts b/src/api/rooms/getRoomDetail.ts new file mode 100644 index 00000000..7a21b057 --- /dev/null +++ b/src/api/rooms/getRoomDetail.ts @@ -0,0 +1,49 @@ +import { apiClient } from '../index'; + +// 방 상세 정보 응답 타입 +export interface RoomDetailResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + isHost: boolean; + isJoining: boolean; + roomId: number; + roomName: string; + roomImageUrl: string; + isPublic: boolean; + progressStartDate: string; + progressEndDate: string; + recruitEndDate: string; + category: string; + roomDescription: string; + memberCount: number; + recruitCount: number; + isbn: string; + bookImageUrl: string; + bookTitle: string; + authorName: string; + bookDescription: string; + publisher: string; + recommendRooms: RecommendRoom[]; + }; +} + +export interface RecommendRoom { + roomId: number; + roomImageUrl: string; + roomName: string; + memberCount: number; + recruitCount: number; + recruitEndDate: string; +} + +export const getRoomDetail = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/recruiting`); + return response.data; + } catch (error) { + console.error('방 상세 정보 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/rooms/getRoomMembers.ts b/src/api/rooms/getRoomMembers.ts new file mode 100644 index 00000000..2ba7f589 --- /dev/null +++ b/src/api/rooms/getRoomMembers.ts @@ -0,0 +1,54 @@ +import { apiClient } from '../index'; + +export interface RoomMember { + userId: number; + nickname: string; + imageUrl: string; + aliasName: string; + followerCount: number; +} + +// 독서메이트 조회 응답 타입 +export interface RoomMembersResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + userList: RoomMember[]; + }; +} + +// 기존 Member 타입과 연결하기 위한 변환 함수 +export interface Member { + id: string; + nickname: string; + role: string; + followersCount?: number; + profileImageUrl?: string; +} + +export const convertRoomMembersToMembers = (roomMembers: RoomMember[]): Member[] => { + const convertedMembers = roomMembers.map(member => { + const convertedMember: Member = { + id: member.userId.toString(), + nickname: member.nickname || '익명', + role: member.aliasName || '독서메이트', + followersCount: member.followerCount || 0, + profileImageUrl: member.imageUrl || undefined, + }; + + return convertedMember; + }); + + return convertedMembers; +}; + +export const getRoomMembers = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/users`); + return response.data; + } catch (error) { + console.error('독서메이트 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/rooms/getRoomPlaying.ts b/src/api/rooms/getRoomPlaying.ts new file mode 100644 index 00000000..f19a120a --- /dev/null +++ b/src/api/rooms/getRoomPlaying.ts @@ -0,0 +1,72 @@ +import { apiClient } from '../index'; + +// 투표 아이템 타입 +export interface VoteItem { + itemName: string; +} + +// 현재 투표 타입 +export interface CurrentVote { + content: string; + page: number; + isOverview: boolean; + voteItems: VoteItem[]; +} + +// 진행중인 방 상세 정보 응답 타입 +export interface RoomPlayingResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + isHost: boolean; + roomId: number; + roomName: string; + roomImageUrl: string; + isPublic: boolean; + progressStartDate: string; + progressEndDate: string; + category: string; + categoryColor: string; + roomDescription: string; + memberCount: number; + recruitCount: number; + isbn: string; + bookTitle: string; + authorName: string; + currentPage: number; + userPercentage: number; + currentVotes: CurrentVote[]; + }; +} + +// HotTopicSection에서 사용할 Poll 타입 (API 데이터를 변환) +export interface Poll { + id: string; + question: string; + options: { id: string; text: string }[]; + pageNumber: number; +} + +// API 데이터를 Poll 형태로 변환하는 함수 +export const convertVotesToPolls = (currentVotes: CurrentVote[]): Poll[] => { + return currentVotes.map((vote, index) => ({ + id: index.toString(), + question: vote.content, + options: vote.voteItems.map((item, itemIndex) => ({ + id: itemIndex.toString(), + text: item.itemName, + })), + pageNumber: vote.page, + })); +}; + +export const getRoomPlaying = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/playing`); + return response.data; + } catch (error) { + console.error('진행중인 방 상세 정보 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/rooms/getRoomsByCategory.ts b/src/api/rooms/getRoomsByCategory.ts new file mode 100644 index 00000000..b2c55583 --- /dev/null +++ b/src/api/rooms/getRoomsByCategory.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 방 목록 응답 데이터 타입 +export interface RoomItem { + roomId: number; + bookImageUrl: string; + roomName: string; + recruitCount: number; + memberCount: number; + deadlineDate: string; +} + +export interface RoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + deadlineRoomList: RoomItem[]; + popularRoomList: RoomItem[]; + }; +} + +export const getRoomsByCategory = async (category: string): Promise => { + try { + const response = await apiClient.get( + `/rooms?category=${encodeURIComponent(category)}`, + ); + return response.data; + } catch (error) { + console.error('방 목록 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/rooms/postCloseRoom.ts b/src/api/rooms/postCloseRoom.ts new file mode 100644 index 00000000..4c900ecf --- /dev/null +++ b/src/api/rooms/postCloseRoom.ts @@ -0,0 +1,20 @@ +import { apiClient } from '../index'; + +export interface PostCloseRoomResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomId: number; + }; +} + +export async function postCloseRoom(roomId: number | string): Promise { + try { + const response = await apiClient.post(`/rooms/${roomId}/close`); + return response.data; + } catch (error) { + console.error('방 닫기 API 오류:', error); + throw error; + } +} diff --git a/src/api/rooms/postJoinRoom.ts b/src/api/rooms/postJoinRoom.ts new file mode 100644 index 00000000..4719ba37 --- /dev/null +++ b/src/api/rooms/postJoinRoom.ts @@ -0,0 +1,28 @@ +import { apiClient } from '../index'; + +export interface PostJoinRoomRequest { + type: 'join' | 'cancel'; +} + +export interface PostJoinRoomResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomId: number; + type: string; + }; +} + +export async function postJoinRoom( + roomId: number | string, + type: 'join' | 'cancel', +): Promise { + try { + const response = await apiClient.post(`/rooms/${roomId}/join`, { type }); + return response.data; + } catch (error) { + console.error('방 참여/취소 API 오류:', error); + throw error; + } +} diff --git a/src/api/users/postSignup.ts b/src/api/users/postSignup.ts index 51b69377..dfc001d5 100644 --- a/src/api/users/postSignup.ts +++ b/src/api/users/postSignup.ts @@ -3,6 +3,7 @@ import { apiClient } from '../index'; export interface PostSignupRequest { aliasName: string; nickname: string; + isTokenRequired: boolean; } export interface PostSignupResponse { diff --git a/src/assets/common/SaveIcon.svg b/src/assets/common/SaveIcon.svg index f0f5b383..34504748 100644 --- a/src/assets/common/SaveIcon.svg +++ b/src/assets/common/SaveIcon.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/src/assets/common/filledSaveIcon.svg b/src/assets/common/filledSaveIcon.svg new file mode 100644 index 00000000..9fffb2da --- /dev/null +++ b/src/assets/common/filledSaveIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/signup/guide1.svg b/src/assets/signup/guide1.svg new file mode 100644 index 00000000..14b4b3a5 --- /dev/null +++ b/src/assets/signup/guide1.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide2.svg b/src/assets/signup/guide2.svg new file mode 100644 index 00000000..4d53a696 --- /dev/null +++ b/src/assets/signup/guide2.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide3.svg b/src/assets/signup/guide3.svg new file mode 100644 index 00000000..d15518f9 --- /dev/null +++ b/src/assets/signup/guide3.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide4.svg b/src/assets/signup/guide4.svg new file mode 100644 index 00000000..95eacc97 --- /dev/null +++ b/src/assets/signup/guide4.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide5.svg b/src/assets/signup/guide5.svg new file mode 100644 index 00000000..f1f07aed --- /dev/null +++ b/src/assets/signup/guide5.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide6.svg b/src/assets/signup/guide6.svg new file mode 100644 index 00000000..95fbccba --- /dev/null +++ b/src/assets/signup/guide6.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/common/BookSearchBottomSheet/BookList.tsx b/src/components/common/BookSearchBottomSheet/BookList.tsx new file mode 100644 index 00000000..416211c7 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookList.tsx @@ -0,0 +1,43 @@ +import { + BookList as StyledBookList, + BookItem, + BookCover, + BookInfo, + BookTitle, +} from './BookSearchBottomSheet.styled'; + +export interface Book { + id: number; + title: string; + author: string; + cover: string; + isbn: string; +} + +interface BookListProps { + books: Book[]; + onBookSelect: (book: Book) => void; +} + +const BookList = ({ books, onBookSelect }: BookListProps) => { + const handleImageError = (e: React.SyntheticEvent) => { + e.currentTarget.style.display = 'none'; + }; + + return ( + + {books.map(book => ( + onBookSelect(book)}> + + {book.title} + + + {book.title} + + + ))} + + ); +}; + +export default BookList; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index a75a27f4..68803dd3 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -104,63 +104,56 @@ export const IconButton = styled.button` export const TabContainer = styled.div` display: flex; gap: 34px; - margin-bottom: 24px; + margin-bottom: 16px; flex-shrink: 0; `; export const Tab = styled.button<{ active: boolean }>` background: none; border: none; - color: ${({ active }) => (active ? semanticColors.text.primary : semanticColors.text.ghost)}; + color: ${({ active }) => (active ? colors.white : colors.grey[300])}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold}; - padding: 8px 0 8px 0; cursor: pointer; position: relative; - ${({ active }) => - active && - ` - &::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background-color: ${semanticColors.text.primary}; - } - `} + &:after { + content: ''; + position: absolute; + bottom: -8px; + left: 5px; + right: 5px; + height: 2px; + background-color: ${({ active }) => (active ? colors.white : 'transparent')}; + transition: background-color 0.2s ease; + } + + &:hover { + color: ${colors.white}; + } `; export const BookListContainer = styled.div` flex: 1; overflow-y: auto; - margin-right: -16px; - padding-right: 16px; + min-height: 0; &::-webkit-scrollbar { - width: 4px; + width: 3px; } &::-webkit-scrollbar-track { - background: ${colors.grey[400]}; - border-radius: 2px; - margin-right: 14px; + background: transparent; } &::-webkit-scrollbar-thumb { - background-color: ${colors.white}; - border-radius: 2px; + background: ${colors.white}; + border-radius: 3px; } &::-webkit-scrollbar-thumb:hover { - background-color: ${colors.grey[200]}; + background: ${colors.grey[100]}; } - - /* Firefox 스크롤바 */ - scrollbar-width: thin; - scrollbar-color: ${colors.white} ${colors.grey[400]}; `; export const BookList = styled.div` @@ -172,10 +165,15 @@ export const BookList = styled.div` export const BookItem = styled.div` display: flex; align-items: center; - gap: 8px; - padding: 12px 0; - border-bottom: 1px solid ${colors.grey[400]}; + padding: 12px 2px; + background-color: none; cursor: pointer; + transition: background-color 0.2s ease; + border-bottom: 1px solid ${colors.grey[400]}; + + &:last-child { + border-bottom: none; + } `; export const BookCover = styled.div` @@ -183,7 +181,8 @@ export const BookCover = styled.div` height: 60px; overflow: hidden; flex-shrink: 0; - background-color: ${semanticColors.background.card}; + margin-right: 8px; + border: 1px solid ${colors.grey[300]}; img { width: 100%; @@ -194,14 +193,82 @@ export const BookCover = styled.div` export const BookInfo = styled.div` flex: 1; + min-width: 0; +`; + +export const BookTitle = styled.h3` + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.white}; + margin: 0; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +// 로딩 상태 스타일 +export const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const LoadingText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; +`; + +// 에러 상태 스타일 +export const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const ErrorText = styled.p` + color: ${colors.red}; + font-size: ${typography.fontSize.base}; + margin: 0; + text-align: center; +`; + +// 빈 상태 스타일 +export const EmptyContainer = styled.div` display: flex; flex-direction: column; - gap: 4px; + justify-content: center; + align-items: center; + height: 200px; + gap: 8px; `; -export const BookTitle = styled.div` - color: ${semanticColors.text.primary}; +export const EmptyText = styled.p` + color: #e0e0e0;}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; - line-height: 1.4; + margin: 0; + text-align: center; +`; + +export const ApplyButton = styled.button` + background-color: ${colors.purple.main}; + color: ${colors.white}; + padding: 10px 12px; + font-size: ${typography.fontSize.base}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + border: none; + border-radius: 12px; + cursor: pointer; + margin-top: 16px; + + &:hover { + background-color: ${colors.purple.dark}; + } `; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 88843320..8216743d 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,33 +1,15 @@ -import { useState, useEffect } from 'react'; -import closeIcon from '../../../assets/group/close.svg'; -import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { useEffect } from 'react'; import { Overlay, BottomSheetContainer, Content, - SearchContainer, - SearchInputWrapper, - SearchInput, - ButtonGroup, - IconButton, - TabContainer, - Tab, BookListContainer, - BookList, - BookItem, - BookCover, - BookInfo, - BookTitle, -} from './BookSearchBottomSheet.styled.ts'; - -// Types -interface Book { - id: number; - title: string; - author: string; - cover: string; - isbn: string; -} +} from './BookSearchBottomSheet.styled'; +import BookSearchHeader from './BookSearchHeader'; +import BookSearchTabs from './BookSearchTabs'; +import BookList, { type Book } from './BookList'; +import BookSearchStates from './BookSearchStates'; +import { useBookSearch } from './useBookSearch'; interface BookSearchBottomSheetProps { isOpen: boolean; @@ -35,81 +17,28 @@ interface BookSearchBottomSheetProps { onSelectBook: (book: Book) => void; } -type TabType = 'saved' | 'group'; - -const mockSavedBooks: Book[] = [ - { - id: 1, - title: '토마토 컵라면', - author: '작가명', - cover: '/src/assets/books/tomato.svg', - isbn: '9780374500016', - }, - { - id: 2, - title: '사슴', - author: '작가명', - cover: '/src/assets/books/deer.svg', - isbn: '9781234567891', - }, - { - id: 3, - title: '호르몬 체인지', - author: '작가명', - cover: '/src/assets/books/hormone.svg', - isbn: '9781234567892', - }, -]; - -const mockGroupBooks: Book[] = [ - { - id: 4, - title: '단 한번의 삶', - author: '작가명', - cover: '/src/assets/books/life.svg', - isbn: '9781234567893', - }, - { - id: 5, - title: '호르몬 체인지', - author: '작가명', - cover: '/src/assets/books/hormone.svg', - isbn: '9781234567892', - }, - { - id: 6, - title: '토마토 컵라면', - author: '작가명', - cover: '/src/assets/books/tomato.svg', - isbn: '9781234567890', - }, -]; - const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { - // State - const [searchQuery, setSearchQuery] = useState(''); - const [filteredBooks, setFilteredBooks] = useState(mockSavedBooks); - const [activeTab, setActiveTab] = useState('saved'); - - // Effects + const { + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + showEmptyState, + showTabs, + setSearchQuery, + handleTabChange, + loadInitialData, + } = useBookSearch(); + + // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - // 현재 활성화된 탭의 책 목록 가져오기 - const currentTabBooks = activeTab === 'saved' ? mockSavedBooks : mockGroupBooks; - - if (searchQuery.trim() === '') { - // 검색어가 없을 때는 선택된 탭의 전체 목록 표시 - setFilteredBooks(currentTabBooks); - } else { - // 검색어가 있을 때는 선택된 탭 내에서만 검색 - const filtered = currentTabBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); + if (isOpen) { + loadInitialData(); } - }, [searchQuery, activeTab]); + }, [isOpen]); + // 바디 스크롤 제어 useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; @@ -135,94 +64,41 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott }; const handleSearch = () => { - // 실제 검색 API 호출 로직 console.log('검색:', searchQuery); }; - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; - - const handleImageError = (e: React.SyntheticEvent) => { - e.currentTarget.style.display = 'none'; - }; - const handleClearSearch = () => { setSearchQuery(''); }; - const handleTabChange = (tab: TabType) => { - setActiveTab(tab); - // 탭 변경 시 현재 검색어로 새로운 탭에서 다시 검색 - const newTabBooks = tab === 'saved' ? mockSavedBooks : mockGroupBooks; - - if (searchQuery.trim() === '') { - setFilteredBooks(newTabBooks); - } else { - const filtered = newTabBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); - } - }; - - // 검색어가 없을 때만 탭 표시 - const showTabs = searchQuery.trim() === ''; + const showBookList = !isLoading && !error && !showEmptyState; return ( - {/* 검색 영역 */} - - - setSearchQuery(e.target.value)} - onKeyPress={handleKeyPress} - /> - - - - 닫기 - - - 검색 - - - + {/* 검색 헤더 */} + - {/* 탭 영역 - 검색어가 없을 때만 표시 */} - {showTabs && ( - - handleTabChange('saved')}> - 저장한 책 - - handleTabChange('group')}> - 모임 책 - - - )} + {/* 탭 영역 */} + {showTabs && } {/* 책 목록 영역 */} - - {filteredBooks.map(book => ( - handleBookSelect(book)}> - - {book.title} - - - {book.title} - - - ))} - + + + {showBookList && } diff --git a/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx b/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx new file mode 100644 index 00000000..8a602165 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx @@ -0,0 +1,52 @@ +import closeIcon from '../../../assets/group/close.svg'; +import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { + SearchContainer, + SearchInputWrapper, + SearchInput, + ButtonGroup, + IconButton, +} from './BookSearchBottomSheet.styled'; + +interface BookSearchHeaderProps { + searchQuery: string; + onSearchChange: (value: string) => void; + onSearch: () => void; + onClear: () => void; +} + +const BookSearchHeader = ({ + searchQuery, + onSearchChange, + onSearch, + onClear, +}: BookSearchHeaderProps) => { + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSearch(); + } + }; + + return ( + + + onSearchChange(e.target.value)} + onKeyPress={handleKeyPress} + /> + + + + 닫기 + + + 검색 + + + + ); +}; + +export default BookSearchHeader; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx b/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx new file mode 100644 index 00000000..f67b2729 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from 'react-router-dom'; +import { + LoadingContainer, + LoadingText, + ErrorContainer, + ErrorText, + EmptyContainer, + EmptyText, + ApplyButton, +} from './BookSearchBottomSheet.styled'; + +interface BookSearchStatesProps { + isLoading: boolean; + error: string | null; + isEmpty: boolean; + activeTab: 'saved' | 'group'; + onClose: () => void; +} + +const BookSearchStates = ({ isLoading, error, isEmpty, onClose }: BookSearchStatesProps) => { + const navigate = useNavigate(); + + const handleApplyBook = () => { + navigate('/search/applybook'); + onClose(); + }; + + if (isLoading) { + return ( + + 책 목록을 불러오는 중... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (isEmpty) { + return ( + + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 + + ); + } + + return null; +}; + +export default BookSearchStates; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx new file mode 100644 index 00000000..ef5e8660 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx @@ -0,0 +1,23 @@ +import { TabContainer, Tab } from './BookSearchBottomSheet.styled'; + +export type TabType = 'saved' | 'group'; + +interface BookSearchTabsProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +const BookSearchTabs = ({ activeTab, onTabChange }: BookSearchTabsProps) => { + return ( + + onTabChange('saved')}> + 저장한 책 + + onTabChange('group')}> + 모임 책 + + + ); +}; + +export default BookSearchTabs; diff --git a/src/components/common/BookSearchBottomSheet/useBookSearch.ts b/src/components/common/BookSearchBottomSheet/useBookSearch.ts new file mode 100644 index 00000000..fe47675a --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/useBookSearch.ts @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react'; +import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; +import type { Book } from './BookList'; +import type { TabType } from './BookSearchTabs'; + +export const useBookSearch = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [filteredBooks, setFilteredBooks] = useState([]); + const [activeTab, setActiveTab] = useState('saved'); + const [savedBooks, setSavedBooks] = useState([]); + const [groupBooks, setGroupBooks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // API에서 받은 데이터를 Book 타입으로 변환하는 함수 + const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ + id: savedBook.bookId, + title: savedBook.bookTitle, + author: savedBook.authorName, + cover: savedBook.bookImageUrl, + isbn: savedBook.isbn, + }); + + // 저장한 책 데이터 가져오기 + const fetchSavedBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('saved'); + + if (response.isSuccess && response.data) { + setSavedBooks(response.data.bookList); + } else { + setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('저장한 책 조회 오류:', err); + setError('저장한 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 모임 책 데이터 가져오기 + const fetchGroupBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('joining'); + + if (response.isSuccess && response.data) { + setGroupBooks(response.data.bookList); + } else { + setError(response.message || '모임 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('모임 책 조회 오류:', err); + setError('모임 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 필터링 로직 + useEffect(() => { + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const convertedBooks = currentTabBooks.map(convertSavedBookToBook); + + if (searchQuery.trim() === '') { + setFilteredBooks(convertedBooks); + } else { + const filtered = convertedBooks.filter( + book => + book.title.toLowerCase().includes(searchQuery.toLowerCase()) || + book.author.toLowerCase().includes(searchQuery.toLowerCase()), + ); + setFilteredBooks(filtered); + } + }, [searchQuery, activeTab, savedBooks, groupBooks]); + + // 탭 변경 핸들러 + const handleTabChange = async (tab: TabType) => { + setActiveTab(tab); + setError(null); + + // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); + } + }; + + // 초기 데이터 로드 + const loadInitialData = () => { + if (activeTab === 'saved' && savedBooks.length === 0) { + fetchSavedBooks(); + } else if (activeTab === 'group' && groupBooks.length === 0) { + fetchGroupBooks(); + } + }; + + // 현재 상태 계산 + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const hasBooks = currentTabBooks.length > 0; + const showEmptyState = !isLoading && !error && !hasBooks; + const showTabs = searchQuery.trim() === '' && !showEmptyState; + + return { + // State + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + hasBooks, + showEmptyState, + showTabs, + + // Actions + setSearchQuery, + handleTabChange, + loadInitialData, + }; +}; diff --git a/src/components/common/Modal/PopupContainer.tsx b/src/components/common/Modal/PopupContainer.tsx index e3b3afae..29bdc1b1 100644 --- a/src/components/common/Modal/PopupContainer.tsx +++ b/src/components/common/Modal/PopupContainer.tsx @@ -69,7 +69,7 @@ const Wrapper = styled.div` min-width: 320px; max-width: 767px; margin: 0 auto; - background-color: rgba(18, 18, 18, 0.1); + background-color: rgba(18, 18, 18, 0.3); backdrop-filter: blur(2.5px); z-index: 1000; `; diff --git a/src/components/common/Modal/ReplyModal.tsx b/src/components/common/Modal/ReplyModal.tsx index 365667c0..2adb16be 100644 --- a/src/components/common/Modal/ReplyModal.tsx +++ b/src/components/common/Modal/ReplyModal.tsx @@ -111,7 +111,7 @@ const ModalContainer = styled.div` padding: 20px 12px; border-radius: 16px; border: 1px solid ${colors.grey[200]}; - background-color: ${colors.black.main}; + background-color: rgba(18, 18, 18, 0.3); z-index: 1001; `; diff --git a/src/components/common/Post/PostHeader.tsx b/src/components/common/Post/PostHeader.tsx index ec49a977..522b2ba9 100644 --- a/src/components/common/Post/PostHeader.tsx +++ b/src/components/common/Post/PostHeader.tsx @@ -8,6 +8,7 @@ interface PostHeaderProps { postDate: string; creatorId?: number; type?: 'post' | 'reply'; + isWriter?: boolean; } const PostHeader = ({ @@ -18,12 +19,18 @@ const PostHeader = ({ postDate, creatorId, type = 'post', + isWriter, }: PostHeaderProps) => { const navigate = useNavigate(); const handleClick = () => { if (creatorId) { - navigate(`/otherfeed/${creatorId}`); + // isWriter가 true면 MyFeedPage로, false면 OtherFeedPage로 이동 + if (isWriter) { + navigate(`/myfeed/${creatorId}`); + } else { + navigate(`/otherfeed/${creatorId}`); + } } }; diff --git a/src/components/common/Post/Reply.tsx b/src/components/common/Post/Reply.tsx index e09c7176..59f2b791 100644 --- a/src/components/common/Post/Reply.tsx +++ b/src/components/common/Post/Reply.tsx @@ -12,6 +12,7 @@ import { deleteComment } from '@/api/comments/deleteComment'; interface ReplyProps extends CommentData { onDelete?: () => void; + isWriter?: boolean; } const Reply = ({ @@ -27,6 +28,7 @@ const Reply = ({ likeCount: initialLikeCount, isDeleted, onDelete, + isWriter, }: ReplyProps) => { const [liked, setLiked] = useState(isLike); const [likeCount, setLikeCount] = useState(initialLikeCount); @@ -121,6 +123,7 @@ const Reply = ({ aliasColor={aliasColor} postDate={postDate} creatorId={creatorId} + isWriter={isWriter} type="reply" /> diff --git a/src/components/common/Post/SubReply.tsx b/src/components/common/Post/SubReply.tsx index 6faae4c0..d83edffa 100644 --- a/src/components/common/Post/SubReply.tsx +++ b/src/components/common/Post/SubReply.tsx @@ -13,6 +13,7 @@ import { deleteComment } from '@/api/comments/deleteComment'; interface SubReplyProps extends ReplyData { onDelete?: () => void; + isWriter?: boolean; } const SubReply = ({ @@ -29,6 +30,7 @@ const SubReply = ({ isLike, isDeleted, onDelete, + isWriter, }: SubReplyProps) => { const [liked, setLiked] = useState(isLike); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); @@ -143,6 +145,7 @@ const SubReply = ({ aliasColor={aliasColor} postDate={postDate} creatorId={creatorId} + isWriter={isWriter} type="reply" /> diff --git a/src/components/common/TokenStatus.tsx b/src/components/common/TokenStatus.tsx new file mode 100644 index 00000000..b852dfe3 --- /dev/null +++ b/src/components/common/TokenStatus.tsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; + +const TokenStatus = () => { + const [tokenStatus, setTokenStatus] = useState('확인 중...'); + + useEffect(() => { + const checkToken = () => { + const cookies = document.cookie.split(';'); + const hasAuthCookie = cookies.some(cookie => cookie.trim().startsWith('Authorization=')); + + if (hasAuthCookie) { + setTokenStatus('✅ Authorization 쿠키 있음'); + } else { + setTokenStatus('🔑 임시 토큰 사용 중'); + } + }; + + checkToken(); + // 5초마다 상태 확인 + const interval = setInterval(checkToken, 5000); + + return () => clearInterval(interval); + }, []); + + return ( +
+ {tokenStatus} +
+ ); +}; + +export default TokenStatus; diff --git a/src/components/createpost/TagSelectionSection.tsx b/src/components/createpost/TagSelectionSection.tsx index 78884b97..311dbd6b 100644 --- a/src/components/createpost/TagSelectionSection.tsx +++ b/src/components/createpost/TagSelectionSection.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Section, SectionTitle } from '../../pages/group/CommonSection.styled'; import { TagContainer, @@ -13,28 +13,42 @@ import { TagCount, } from './TagSelectionSection.styled'; import closeIcon from '../../assets/post/close.svg'; +import { getWriteInfo, type CategoryData } from '@/api/feeds/getWriteInfo'; interface TagSelectionSectionProps { selectedTags: string[]; onTagToggle: (tag: string) => void; } -// 상위 장르와 하위 태그 매핑 -const genreTagsMap: Record = { - 문학: ['소설', '시', '에세이', '인문학', '철학'], - '과학·IT': ['기술', '과학', 'AI', '데이터'], - 사회과학: ['정치', '경제', '사회학', '심리학', '역사'], - 인문학: ['철학', '역사', '문화', '언어학', '종교'], - 예술: ['미술', '음악', '영화', '디자인', '사진'], -}; +const TagSelectionSection = ({ selectedTags, onTagToggle }: TagSelectionSectionProps) => { + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(''); + const [loading, setLoading] = useState(true); -const availableGenres = Object.keys(genreTagsMap); + // API에서 카테고리 및 태그 데이터 로드 + useEffect(() => { + const loadWriteInfo = async () => { + try { + setLoading(true); + const response = await getWriteInfo(); -const TagSelectionSection = ({ selectedTags, onTagToggle }: TagSelectionSectionProps) => { - const [selectedGenre, setSelectedGenre] = useState('문학'); + if (response.isSuccess && response.data.categoryList.length > 0) { + setCategories(response.data.categoryList); + // 첫 번째 카테고리를 기본 선택 + setSelectedCategory(response.data.categoryList[0].category); + } + } catch (error) { + console.error('카테고리 정보 로드 실패:', error); + } finally { + setLoading(false); + } + }; - const handleGenreSelect = (genre: string) => { - setSelectedGenre(genre); + loadWriteInfo(); + }, []); + + const handleCategorySelect = (category: string) => { + setSelectedCategory(category); }; const handleTagToggle = (tag: string) => { @@ -54,28 +68,38 @@ const TagSelectionSection = ({ selectedTags, onTagToggle }: TagSelectionSectionP onTagToggle(tag); }; - const currentSubTags = genreTagsMap[selectedGenre] || []; + // 현재 선택된 카테고리의 태그 목록 + const currentTags = categories.find(cat => cat.category === selectedCategory)?.tagList || []; + + if (loading) { + return ( +
+ 태그 + +
로딩 중...
+
+
+ ); + } return (
태그 - {/* 상위 장르 선택 */} - {availableGenres.map(genre => ( + {categories.map(categoryData => ( handleGenreSelect(genre)} + key={categoryData.category} + active={selectedCategory === categoryData.category} + onClick={() => handleCategorySelect(categoryData.category)} > - {genre} + {categoryData.category} ))} - {/* 하위 태그 그리드 */} - {currentSubTags.map(tag => ( + {currentTags.map(tag => ( { + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/search/book/${isbn}`); + }; + + return ( + +
{bookTitle}
+
+
{bookAuthor}
+
+ +
+
+ ); +}; + const BookContainer = styled.div` display: flex; height: 44px; @@ -48,29 +73,4 @@ const BookContainer = styled.div` } `; -interface BookInfoCardProps { - bookTitle: string; - bookAuthor: string; - isbn: string; -} - -const BookInfoCard = ({ bookTitle, bookAuthor, isbn }: BookInfoCardProps) => { - const navigate = useNavigate(); - - const handleClick = () => { - navigate(`/book/${isbn}`); - }; - - return ( - -
{bookTitle}
-
-
{bookAuthor}
-
- -
-
- ); -}; - export default BookInfoCard; diff --git a/src/components/feed/OtherFeed.tsx b/src/components/feed/OtherFeed.tsx index 08d3d5ca..ce36a001 100644 --- a/src/components/feed/OtherFeed.tsx +++ b/src/components/feed/OtherFeed.tsx @@ -12,9 +12,16 @@ interface OtherFeedProps { isMyFeed?: boolean; profileData?: OtherProfileData | null; userId?: number; + showFollowButton?: boolean; // showFollowButton prop 추가 } -const OtherFeed = ({ posts = [], profileData, userId }: OtherFeedProps) => { +const OtherFeed = ({ + posts = [], + profileData, + userId, + showFollowButton, + isMyFeed, +}: OtherFeedProps) => { const hasPosts = posts.length > 0; if (!profileData) { @@ -25,7 +32,7 @@ const OtherFeed = ({ posts = [], profileData, userId }: OtherFeedProps) => { { aliasColor={profileData.aliasColor} followerCount={profileData.followerCount} latestFollowerProfileImageUrls={profileData?.latestFollowerProfileImageUrls || []} + isMyFeed={isMyFeed} /> {hasPosts ? ( posts.map(post => ( - + )) ) : ( diff --git a/src/components/feed/Profile.tsx b/src/components/feed/Profile.tsx index d4cffed8..14716783 100644 --- a/src/components/feed/Profile.tsx +++ b/src/components/feed/Profile.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import styled from '@emotion/styled'; import MyFollower from './MyFollower'; import { postFollow } from '@/api/users/postFollow'; +import { usePopupStore } from '@/stores/usePopupStore'; export interface ProfileProps { showFollowButton?: boolean; @@ -13,6 +14,7 @@ export interface ProfileProps { followerCount: number; latestFollowerProfileImageUrls?: string[]; userId?: number; + isMyFeed?: boolean; } const Profile = ({ @@ -25,8 +27,10 @@ const Profile = ({ followerCount, latestFollowerProfileImageUrls = [], userId, + isMyFeed, }: ProfileProps) => { const [followed, setFollowed] = useState(isFollowing); + const { openPopup } = usePopupStore(); useEffect(() => { setFollowed(isFollowing); @@ -50,6 +54,17 @@ const Profile = ({ // API 응답으로 팔로우 상태 업데이트 setFollowed(response.data.isFollowing); console.log(`${nickname} - ${response.data.isFollowing ? '띱 완료' : '띱 취소'}`); + + // Snackbar 표시 + const message = response.data.isFollowing + ? `${nickname}님을 띱 했어요.` + : `${nickname}님을 띱 취소했어요.`; + + openPopup('snackbar', { + message, + variant: 'top', + onClose: () => {}, + }); } catch (error) { console.error('팔로우/언팔로우 실패:', error); // 에러 발생 시 상태 변경하지 않음 @@ -68,7 +83,7 @@ const Profile = ({ - {showFollowButton && ( + {showFollowButton && !isMyFeed && (
{followed ? '띱 취소' : '띱 하기'}
diff --git a/src/components/feed/UserProfileItem.tsx b/src/components/feed/UserProfileItem.tsx index d4c16639..9d19f961 100644 --- a/src/components/feed/UserProfileItem.tsx +++ b/src/components/feed/UserProfileItem.tsx @@ -5,6 +5,7 @@ import rightArrow from '../../assets/feed/rightArrow.svg'; import type { UserProfileItemProps } from '@/types/user'; import { colors, typography } from '@/styles/global/global'; import { postFollow } from '@/api/users/postFollow'; +import { usePopupStore } from '@/stores/usePopupStore'; const UserProfileItem = ({ profileImgUrl, @@ -19,6 +20,7 @@ const UserProfileItem = ({ }: UserProfileItemProps) => { const navigate = useNavigate(); const [followed, setFollowed] = useState(isFollowing); + const { openPopup } = usePopupStore(); const handleProfileClick = () => { navigate(`/otherfeed/${userId}`); @@ -32,6 +34,17 @@ const UserProfileItem = ({ // API 응답으로 팔로우 상태 업데이트 setFollowed(response.data.isFollowing); console.log(`${nickname} - ${response.data.isFollowing ? '띱 완료' : '띱 취소'}`); + + // Snackbar 표시 + const message = response.data.isFollowing + ? `${nickname}님을 띱 했어요.` + : `${nickname}님을 띱 취소했어요.`; + + openPopup('snackbar', { + message, + variant: 'top', + onClose: () => {} + }); } catch (error) { console.error('팔로우/언팔로우 실패:', error); } diff --git a/src/components/group/CompletedGroupModal.tsx b/src/components/group/CompletedGroupModal.tsx index b0954499..b2708cd1 100644 --- a/src/components/group/CompletedGroupModal.tsx +++ b/src/components/group/CompletedGroupModal.tsx @@ -1,86 +1,58 @@ +import { useState, useEffect } from 'react'; import styled from '@emotion/styled'; import leftArrow from '../../assets/common/leftArrow.svg'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; import TitleHeader from '../common/TitleHeader'; import { Modal, Overlay } from './Modal.styles'; +import { getMyRooms, type Room } from '@/api/rooms/getMyRooms'; +import { colors, typography } from '@/styles/global/global'; interface CompletedGroupModalProps { onClose: () => void; } -const dummyCompletedGroups: Group[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, -]; +const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { + const [rooms, setRooms] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -const userName = '00'; + const convertRoomToGroup = (room: Room): Group => { + return { + id: room.roomId.toString(), + title: room.roomName, + userName: '', + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.bookImageUrl, + deadLine: 0, + isOnGoing: false, + }; + }; -const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { + useEffect(() => { + const fetchCompletedRooms = async () => { + try { + setIsLoading(true); + setError(null); + const response = await getMyRooms('expired', null); + if (response.isSuccess) { + setRooms(response.data.roomList); + } else { + setError(response.message); + } + } catch (error) { + console.error('완료된 방 목록 조회 실패:', error); + setError('완료된 방 목록을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchCompletedRooms(); + }, []); + + const convertedGroups = rooms.map(convertRoomToGroup); return ( @@ -89,11 +61,20 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { leftIcon={뒤로 가기} onLeftClick={onClose} /> - {userName}님이 참여했던 모임방들을 확인해보세요. - - {dummyCompletedGroups.map(group => ( - - ))} + 00님이 참여했던 모임방들을 확인해보세요. + + {isLoading ? ( + 로딩 중... + ) : error ? ( + {error} + ) : convertedGroups.length > 0 ? ( + convertedGroups.map(group => ) + ) : ( + + 완료된 모임방이 없어요 + 아직 완료된 모임방이 없습니다. + + )} @@ -103,21 +84,63 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { export default CompletedGroupModal; const Text = styled.p` - font-size: var(--font-size-medium01); - font-weight: var(--font-weight-regular); - color: var(--color-white); + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.white}; margin: 96px 20px 20px 20px; `; -const Content = styled.div` +const Content = styled.div<{ isEmpty?: boolean }>` display: grid; gap: 20px; - overflow-y: auto; + overflow-y: ${({ isEmpty }) => (isEmpty ? 'visible' : 'auto')}; padding: 0 20px; - + flex: 1; grid-template-columns: 1fr; @media (min-width: 584px) { grid-template-columns: 1fr 1fr; } `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.white}; + font-size: ${typography.fontSize.base}; +`; + +const ErrorMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #ff6b6b; + font-size: ${typography.fontSize.base}; +`; + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.grey[100]}; + text-align: center; + height: 100%; +`; + +const EmptyTitle = styled.p` + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + margin-bottom: 8px; + color: ${colors.white}; +`; + +const EmptySubText = styled.p` + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.grey[100]}; +`; diff --git a/src/components/group/GroupCard.tsx b/src/components/group/GroupCard.tsx index 1396b271..c238c7bf 100644 --- a/src/components/group/GroupCard.tsx +++ b/src/components/group/GroupCard.tsx @@ -9,12 +9,13 @@ interface Props { isOngoing?: boolean; type?: 'main' | 'search' | 'modal'; isRecommend?: boolean; + onClick?: () => void; } export const GroupCard = forwardRef( - ({ group, isOngoing, type = 'main', isRecommend = false }, ref) => { + ({ group, isOngoing, type = 'main', isRecommend = false, onClick }, ref) => { return ( - + {group.title} @@ -52,7 +53,9 @@ const Card = styled.div<{ cardType: 'main' | 'search' | 'modal' }>` box-sizing: border-box; padding: ${({ cardType }) => (cardType === 'search' ? '24px 12px 12px 12px' : '12px')}; gap: 12px; - width: 100%; + min-width: 208px; + min-height: 80px; + padding: 12px; `; const Cover = styled.img<{ cardType: 'main' | 'search' | 'modal'; isRecommend?: boolean }>` diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 2a74a48d..b1e297b1 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -2,6 +2,10 @@ import { MyGroupCard } from './MyGroupCard'; import { useInfiniteCarousel } from '../../hooks/useInfiniteCarousel'; import styled from '@emotion/styled'; import rightChevron from '../../assets/common/right-Chevron.svg'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getJoinedRooms, type JoinedRoomItem } from '@/api/rooms/getJoinedRooms'; +import { colors, typography } from '@/styles/global/global'; export interface Group { id: number | string; @@ -16,13 +20,52 @@ export interface Group { isOnGoing?: boolean; } +const convertJoinedRoomToGroup = (room: JoinedRoomItem): Group => ({ + id: room.roomId, + title: room.roomTitle, + participants: room.memberCount, + coverUrl: room.bookImageUrl, + progress: room.userPercentage, +}); + interface MyGroupProps { - groups: Group[]; onMyGroupsClick: () => void; } -export function MyGroupBox({ groups, onMyGroupsClick }: MyGroupProps) { - const { scrollRef, cardRefs, infiniteGroups, current } = useInfiniteCarousel(groups); +export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const fetchJoinedRooms = async () => { + try { + setLoading(true); + setError(null); + const response = await getJoinedRooms(1); + + if (response.isSuccess) { + const convertedGroups = response.data.roomList.map(convertJoinedRoomToGroup); + setGroups(convertedGroups); + } + } catch (error) { + console.error('가입한 방 목록 조회 오류:', error); + setError('방 목록을 불러오는데 실패했습니다.'); + setGroups([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchJoinedRooms(); + }, []); + + const handleCardClick = (roomId: number | string) => { + navigate(`detail/joined/${roomId}`); + }; + + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(groups); return ( @@ -32,28 +75,40 @@ export function MyGroupBox({ groups, onMyGroupsClick }: MyGroupProps) { 내 모임방 버튼 - - {infiniteGroups.map((g, i) => ( - { - cardRefs.current[i] = el; - }} - /> - ))} - - - {groups.map((_, i) => ( - - ))} - + {loading ? ( + + 모임방을 불러오는 중... + + ) : error ? ( + + {error} + + ) : groups.length > 0 ? ( + <> + + {infiniteGroups.map((g, i) => ( + { + cardRefs.current[i] = el; + }} + onClick={() => handleCardClick(g.id)} + /> + ))} + + + ) : ( + + 가입한 모임방이 없어요 + + )} ); } const Container = styled.div` - background-color: var(--color-main-black); + background-color: ${colors.black.main}; position: relative; width: 100%; overflow-x: hidden; @@ -67,9 +122,9 @@ const Header = styled.div` const Title = styled.h2` flex: 1; - font-size: var(--font-size-large02); - font-weight: var(--font-weight-bold); - color: var(--color-white); + font-size: ${typography.fontSize.xl}; + font-weight: ${typography.fontWeight.bold}; + color: ${colors.white}; margin: 0; `; @@ -95,17 +150,41 @@ const Carousel = styled.div` scroll-snap-type: x mandatory; `; -const Dots = styled.div` +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 60px 20px; +`; + +const LoadingText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; +`; + +const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 60px 20px; +`; + +const ErrorText = styled.p` + color: ${colors.red}; + font-size: ${typography.fontSize.base}; + margin: 0; +`; + +const EmptyContainer = styled.div` display: flex; justify-content: center; - gap: 12px; - margin: 30px 0; + align-items: center; + padding: 60px 20px; `; -const Dot = styled.div<{ active: boolean }>` - width: 4px; - height: 4px; - border-radius: 50%; - background: ${({ active }) => (active ? 'var(--color-white)' : `var(--color-grey-300)`)}; - transition: background-color 0.3s; +const EmptyText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; `; diff --git a/src/components/group/MyGroupCard.tsx b/src/components/group/MyGroupCard.tsx index e06586e6..8734c2b8 100644 --- a/src/components/group/MyGroupCard.tsx +++ b/src/components/group/MyGroupCard.tsx @@ -5,11 +5,13 @@ import type { Group } from './MyGroupBox'; interface MyGroupCardProps { group: Group; + onClick?: () => void; } -export const MyGroupCard = forwardRef(({ group }, ref) => { +export const MyGroupCard = forwardRef((props, ref) => { + const { group, onClick } = props; return ( - +
diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index dc036d90..3731fd7d 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -1,96 +1,69 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import styled from '@emotion/styled'; import TitleHeader from '../common/TitleHeader'; import leftArrow from '../../assets/common/leftArrow.svg'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; import { Modal, Overlay } from './Modal.styles'; +import { getMyRooms, type Room, type RoomType } from '@/api/rooms/getMyRooms'; +import { colors, typography } from '@/styles/global/global'; interface MyGroupModalProps { onClose: () => void; } -const dummyMyGroups: Group[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - isOnGoing: true, - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - isOnGoing: true, - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - isOnGoing: true, - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - isOnGoing: true, - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - isOnGoing: false, - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - isOnGoing: false, - }, -]; - export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { const [selected, setSelected] = useState<'진행중' | '모집중' | ''>(''); + const [rooms, setRooms] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const convertRoomToGroup = (room: Room): Group => { + return { + id: room.roomId.toString(), + title: room.roomName, + userName: '', + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.bookImageUrl, + deadLine: 0, + genre: '', + isOnGoing: room.type === 'playing' || room.type === 'playingAndRecruiting', + }; + }; + + useEffect(() => { + const fetchRooms = async () => { + try { + setIsLoading(true); + setError(null); + + const roomType: RoomType = + selected === '진행중' + ? 'playing' + : selected === '모집중' + ? 'recruiting' + : 'playingAndRecruiting'; + + const response = await getMyRooms(roomType, null); + + if (response.isSuccess) { + setRooms(response.data.roomList); + } else { + setError(response.message); + } + } catch (error) { + console.error('방 목록 조회 실패:', error); + setError('방 목록을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; - const filtered = selected - ? dummyMyGroups.filter(g => (selected === '진행중' ? g.isOnGoing : !g.isOnGoing)) - : dummyMyGroups; + fetchRooms(); + }, [selected]); + + const convertedGroups = rooms.map(convertRoomToGroup); return ( @@ -113,14 +86,32 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { - {filtered.map(group => ( - - ))} + {isLoading ? ( + 로딩 중... + ) : error ? ( + {error} + ) : convertedGroups.length > 0 ? ( + convertedGroups.map(group => ( + + )) + ) : ( + + + {selected === '진행중' + ? '진행중인 모임방이 없어요' + : selected === '모집중' + ? '모집중인 모임방이 없어요' + : '참여중인 모임방이 없어요'} + + + {selected === '진행중' + ? '진행중인 모임방에 참여해보세요!' + : selected === '모집중' + ? '모집중인 모임방에 참여해보세요!' + : '첫 번째 모임방에 참여해보세요!'} + + + )} @@ -135,13 +126,12 @@ const TabContainer = styled.div` const Tab = styled.button<{ selected: boolean }>` white-space: nowrap; - padding: 6px 12px; - font-size: var(--font-size-small03); - font-weight: var(--font-weight-regular); + padding: 8px 12px; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; border: none; border-radius: 16px; - background: ${({ selected }) => - selected ? 'var(--color-purple-main)' : 'var(--color-darkgrey-main)'}; + background: ${({ selected }) => (selected ? colors.purple.main : colors.darkgrey.main)}; color: #fff; cursor: pointer; `; @@ -158,3 +148,47 @@ const Content = styled.div` grid-template-columns: 1fr 1fr; } `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #fff; + font-size: ${typography.fontSize.base}; +`; + +const ErrorMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #ff6b6b; + font-size: ${typography.fontSize.base}; +`; + +const EmptyState = styled.div` + flex: 1; + min-height: 78vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 40px 20px; + margin-bottom: 70px; + color: ${colors.grey[100]}; + text-align: center; +`; + +const EmptyTitle = styled.p` + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + margin-bottom: 8px; + color: ${colors.white}; +`; + +const EmptySubText = styled.p` + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.grey[100]}; +`; diff --git a/src/components/group/RecruitingGroupBox.tsx b/src/components/group/RecruitingGroupBox.tsx index 2b8c134b..cfa97b9a 100644 --- a/src/components/group/RecruitingGroupBox.tsx +++ b/src/components/group/RecruitingGroupBox.tsx @@ -2,6 +2,9 @@ import { useMemo, useState } from 'react'; import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; +import { colors, typography } from '@/styles/global/global'; +import { useNavigate } from 'react-router-dom'; +import { getRoomDetail } from '@/api/rooms/getRoomDetail'; interface Props { groups: Group[]; @@ -12,9 +15,25 @@ const GENRE = ['문학', '과학·IT', '사회과학', '인문학', '예술']; export function RecruitingGroupBox({ groups, title }: Props) { const [selected, setSelected] = useState('문학'); + const navigate = useNavigate(); const filtered = useMemo(() => groups.filter(g => g.genre === selected), [groups, selected]); + const handleGroupCardClick = async (groupId: number | string) => { + try { + const roomId = typeof groupId === 'string' ? parseInt(groupId) : groupId; + + const response = await getRoomDetail(roomId); + + if (response.isSuccess) { + navigate(`/group/detail/${roomId}`); + } + } catch (error) { + console.error('방 상세 정보 조회 오류:', error); + navigate(`/group/${groupId}`); + } + }; + return ( {title} @@ -26,9 +45,21 @@ export function RecruitingGroupBox({ groups, title }: Props) { ))} - {filtered.map(group => ( - - ))} + {filtered.length > 0 ? ( + filtered.map(group => ( + handleGroupCardClick(group.id)} + /> + )) + ) : ( + + 모임방이 아직 없어요. + 해당 장르의 모임방이 생기면 보여줄게요! + + )} ); @@ -95,3 +126,29 @@ const Grid = styled.div` grid-template-columns: 1fr 1fr; } `; + +const EmptyContent = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + padding: 60px 20px; + grid-column: 1 / -1; +`; + +const EmptyMainText = styled.p` + color: ${colors.white}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + text-align: center; + margin: 0; +`; + +const EmptySubText = styled.p` + color: ${colors.grey[100]}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + text-align: center; + margin: 0; +`; diff --git a/src/components/group/RecruitingGroupCarousel.tsx b/src/components/group/RecruitingGroupCarousel.tsx index 5f69cfd9..157eff3a 100644 --- a/src/components/group/RecruitingGroupCarousel.tsx +++ b/src/components/group/RecruitingGroupCarousel.tsx @@ -1,7 +1,7 @@ -import { useRef, useEffect, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { RecruitingGroupBox } from './RecruitingGroupBox'; +import { useInfiniteCarousel } from '@/hooks/useInfiniteCarousel'; export interface Section { title: string; @@ -13,59 +13,26 @@ interface Props { } export function RecruitingGroupCarousel({ sections }: Props) { - const scrollRef = useRef(null); - const itemRefs = useRef>([]); + const sectionGroups = sections.map(sec => ({ + ...sec.groups[0], + title: sec.title, + groups: sec.groups, + })); - const handleScroll = useCallback(() => { - const container = scrollRef.current; - if (!container) return; - - const centerX = container.offsetWidth / 2; - const scrollLeft = container.scrollLeft; - - itemRefs.current.forEach(item => { - if (!item) return; - const itemCenter = item.offsetLeft + item.offsetWidth / 2; - const dist = Math.abs(itemCenter - scrollLeft - centerX); - const ratio = Math.min(dist / centerX, 1); - const scale = 1 - ratio * 0.1; - item.style.transform = `scale(${scale})`; - }); - }, []); - - useEffect(() => { - const container = scrollRef.current; - if (!container || sections.length === 0) return; - - const mid = Math.floor(sections.length / 2); - const midItem = itemRefs.current[mid]; - if (midItem) { - const centerX = container.offsetWidth / 2; - const targetScroll = midItem.offsetLeft + midItem.offsetWidth / 2 - centerX; - container.scrollTo({ left: targetScroll, behavior: 'auto' }); - } - handleScroll(); - }, [sections.length, handleScroll]); - - useEffect(() => { - const container = scrollRef.current; - if (!container) return; - container.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - container.removeEventListener('scroll', handleScroll); - }; - }, [handleScroll]); + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(sectionGroups, { + scaleAmount: 0.08, + }); return ( - {sections.map((sec, i) => ( + {infiniteGroups.map((g, i) => ( { - itemRefs.current[i] = el; + cardRefs.current[i] = el; }} > - + ))} @@ -88,4 +55,5 @@ const Item = styled.div` max-width: 640px; scroll-snap-align: center; transition: transform 0.2s; + height: 800px; `; diff --git a/src/components/members/MemberList.styled.ts b/src/components/members/MemberList.styled.ts index f3240c39..429be39b 100644 --- a/src/components/members/MemberList.styled.ts +++ b/src/components/members/MemberList.styled.ts @@ -48,8 +48,8 @@ export const ProfileSection = styled.div` `; export const ProfileImage = styled.div` - width: 48px; - height: 48px; + width: 36px; + height: 36px; border-radius: 50%; background-color: ${colors.grey['400']}; flex-shrink: 0; diff --git a/src/components/members/MemberList.tsx b/src/components/members/MemberList.tsx index bbbc9733..b50d4347 100644 --- a/src/components/members/MemberList.tsx +++ b/src/components/members/MemberList.tsx @@ -1,7 +1,8 @@ import { useNavigate } from 'react-router-dom'; import type { KeyboardEvent } from 'react'; +import styled from '@emotion/styled'; import rightChevron from '../../assets/member/right-chevron.svg'; -import type { Member } from '../../mocks/members.mock'; +import type { Member } from '@/api/rooms/getRoomMembers'; import { Container, MemberItem, @@ -47,7 +48,11 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { }} > - + {member.profileImageUrl ? ( + + ) : ( + + )} {member.nickname} {member.role} @@ -63,4 +68,14 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { ); }; +// 프로필 이미지가 있을 때 사용하는 스타일드 컴포넌트 +const ProfileImageWithSrc = styled.img` + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--color-grey-400); + flex-shrink: 0; + object-fit: cover; +`; + export default MemberList; diff --git a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx index 13527d3b..dbdc0eaf 100644 --- a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx +++ b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import plusIcon from '../../../assets/memory/plus.svg'; import penIcon from '../../../assets/memory/pen.svg'; import voteIcon from '../../../assets/memory/vote.svg'; @@ -7,6 +7,7 @@ import { AddButton, DropdownContainer, DropdownItem } from './MemoryAddButton.st const MemoryAddButton = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); // useParams 추가 const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -33,14 +34,22 @@ const MemoryAddButton = () => { const handleRecordWrite = () => { setIsOpen(false); - navigate('/memory/record/write'); - console.log('기록 작성하기'); + + // URL에서 roomId를 가져오거나 기본값 1 사용 + const currentRoomId = roomId || '1'; + + navigate(`/memory/record/write/${currentRoomId}`); + console.log('기록 작성하기 - roomId:', currentRoomId); }; const handlePollCreate = () => { setIsOpen(false); - navigate('/memory/poll/write'); - console.log('투표 생성하기'); + + // URL에서 roomId를 가져오거나 기본값 1 사용 + const currentRoomId = roomId || '1'; + + navigate(`/memory/poll/write/${currentRoomId}`); + console.log('투표 생성하기 - roomId:', currentRoomId); }; return ( diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index 8b7bb27f..a715d897 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useState, useRef, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import type { Record } from '../../../pages/memory/Memory'; import TextRecord from './TextRecord'; import PollRecord from './PollRecord'; @@ -17,6 +18,9 @@ import { ActionSection, ActionButton, } from './RecordItem.styled'; +import { usePopupActions } from '@/hooks/usePopupActions'; +import { deleteRecord } from '@/api/record/deleteRecord'; +import { deleteVote } from '@/api/record/deleteVote'; interface RecordItemProps { record: Record; @@ -24,6 +28,10 @@ interface RecordItemProps { } const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { + const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + const { openMoreMenu, openConfirm, openSnackbar, closePopup } = usePopupActions(); + const { user, content, @@ -34,12 +42,22 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { pollOptions, pageRange, recordType, + isWriter, } = record; // 좋아요 상태 관리 const [isLiked, setIsLiked] = useState(false); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); + // 길게 누르기 상태 관리 + const [isPressed, setIsPressed] = useState(false); + const longPressTimer = useRef(null); + const pressStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const hasTriggeredLongPress = useRef(false); + + // API에서 받은 isWriter 속성으로 내 기록인지 판단 + const isMyRecord = isWriter ?? false; + const handleLikeClick = () => { if (isLiked) { // 좋아요 취소 @@ -62,8 +80,152 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { return '0p'; // 기본값 }; + const handleEdit = useCallback(() => { + const currentRoomId = roomId || '1'; + + if (type === 'poll') { + navigate(`/memory/poll/edit/${currentRoomId}/${record.id}`); + } else { + navigate(`/memory/record/edit/${currentRoomId}/${record.id}`); + } + }, [roomId, type, record.id, navigate]); + + const handleDelete = useCallback(async () => { + const currentRoomId = roomId || '1'; + const recordId = parseInt(record.id); + + try { + let response; + + if (type === 'poll') { + response = await deleteVote(parseInt(currentRoomId), recordId); + } else { + response = await deleteRecord(parseInt(currentRoomId), recordId); + } + + if (response.isSuccess) { + const recordTypeName = type === 'poll' ? '투표' : '기록'; + openSnackbar({ + message: `${recordTypeName}가 삭제되었습니다.`, + variant: 'top', + onClose: () => {}, + }); + + // TODO: 목록에서 해당 기록 제거 (부모 컴포넌트 업데이트 필요) + // 현재는 페이지 새로고침으로 임시 처리 + window.location.reload(); + } else { + openSnackbar({ + message: '삭제에 실패했습니다. 다시 시도해주세요.', + variant: 'top', + onClose: () => {}, + }); + } + } catch (error) { + console.error('삭제 중 오류 발생:', error); + openSnackbar({ + message: '삭제 중 오류가 발생했습니다.', + variant: 'top', + onClose: () => {}, + }); + } + }, [roomId, record.id, type, openSnackbar]); + + const handleDeleteConfirm = useCallback(() => { + const recordTypeName = type === 'poll' ? '투표' : '기록'; + + openConfirm({ + title: `${recordTypeName}을 삭제하시겠어요?`, + disc: `삭제된 ${recordTypeName}은 복구할 수 없습니다.`, + onConfirm: handleDelete, + }); + }, [type, openConfirm, handleDelete]); + + const handleReport = useCallback(() => { + openSnackbar({ + message: '신고가 접수되었습니다.', + variant: 'top', + onClose: () => {}, + }); + }, [openSnackbar]); + + const handleLongPress = useCallback(() => { + if (isMyRecord) { + // 내 기록: 수정하기, 삭제하기 + openMoreMenu({ + onEdit: handleEdit, + onDelete: handleDeleteConfirm, + onClose: closePopup, + }); + } else { + // 다른 사람 기록: 신고하기 + handleReport(); + } + }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm, handleReport, closePopup]); + + const handleLongPressStart = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + if ('touches' in e) { + e.preventDefault(); + } + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + pressStartPos.current = { x: clientX, y: clientY }; + hasTriggeredLongPress.current = false; + setIsPressed(true); + + longPressTimer.current = setTimeout(() => { + if (!hasTriggeredLongPress.current) { + hasTriggeredLongPress.current = true; + handleLongPress(); + } + }, 500); + }, + [handleLongPress], + ); + + const handleLongPressEnd = useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + setIsPressed(false); + }, []); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!longPressTimer.current) return; + + const clientX = e.touches[0].clientX; + const clientY = e.touches[0].clientY; + + const deltaX = Math.abs(clientX - pressStartPos.current.x); + const deltaY = Math.abs(clientY - pressStartPos.current.y); + + if (deltaX > 10 || deltaY > 10) { + handleLongPressEnd(); + } + }, + [handleLongPressEnd], + ); + return ( - + diff --git a/src/components/search/BookSearchResult.tsx b/src/components/search/BookSearchResult.tsx index 39b26624..4d6c9d6d 100644 --- a/src/components/search/BookSearchResult.tsx +++ b/src/components/search/BookSearchResult.tsx @@ -6,10 +6,20 @@ import { useNavigate } from 'react-router-dom'; interface BookSearchResultProps { type: 'searching' | 'searched'; searchedBookList: SearchedBook[]; + hasMore?: boolean; + isLoading?: boolean; + lastBookElementCallback?: (node: HTMLDivElement | null) => void; } -export function BookSearchResult({ type, searchedBookList }: BookSearchResultProps) { +export function BookSearchResult({ + type, + searchedBookList, + hasMore = false, + isLoading = false, + lastBookElementCallback, +}: BookSearchResultProps) { const navigate = useNavigate(); + const isEmptySearchedBookList = () => { if (searchedBookList.length === 0) return true; else return false; @@ -18,6 +28,7 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro const handleApplyBook = () => { navigate('/search/applybook'); }; + return ( @@ -30,8 +41,16 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro 책 신청하기 ) : ( - searchedBookList.map(book => ( - + searchedBookList.map((book, index) => ( + navigate(`/search/book/${book.isbn}`)} + ref={ + index === searchedBookList.length - 1 && lastBookElementCallback + ? lastBookElementCallback + : undefined + } + > {book.title} @@ -42,6 +61,12 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro )) )} + + {/* 로딩 상태 표시 */} + {isLoading && searchedBookList.length > 0 && <>} + + {/* 더 이상 데이터가 없음을 표시 */} + {!hasMore && searchedBookList.length > 0 && <>} ); @@ -63,6 +88,7 @@ const BookItem = styled.div` display: flex; border-bottom: 1px solid ${colors.darkgrey.dark}; padding: 12px 0; + cursor: pointer; `; const ResultHeader = styled.div` diff --git a/src/components/search/MostSearchedBooks.tsx b/src/components/search/MostSearchedBooks.tsx index a485b25c..cacab507 100644 --- a/src/components/search/MostSearchedBooks.tsx +++ b/src/components/search/MostSearchedBooks.tsx @@ -1,64 +1,71 @@ import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getMostSearchedBooks, type MostSearchedBook } from '@/api/books/getMostSearchedBooks'; -interface Book { - id: number; - title: string; - coverUrl: string; -} +export default function MostSearchedBooks() { + const [books, setBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const navigate = useNavigate(); -const dummyBooks: Book[] = [ - { - id: 1, - title: '토마토 컵라면', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 2, - title: '사슴', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 3, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 4, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 5, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 6, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, -]; + useEffect(() => { + const fetchMostSearchedBooks = async () => { + try { + setIsLoading(true); + const response = await getMostSearchedBooks(); -export default function MostSearchedBooks() { + if (response.isSuccess) { + setBooks(response.data.bookList); + } else { + setError(response.message); + } + } catch (error) { + console.error('인기 검색 도서 조회 오류:', error); + setError('인기 검색 도서를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchMostSearchedBooks(); + }, []); + + const handleBookClick = (isbn: string) => { + navigate(`/search/book/${isbn}`); + }; + + const getCurrentDate = () => { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${month}.${day}. 기준`; + }; return (
가장 많이 검색된 책 - {/* 서버 응답 포맷을 모르기에 우선 하드 코딩 */} - 01.12. 기준 + {getCurrentDate()}
- {dummyBooks.length === 0 ? ( + {isLoading ? ( + 로딩 중... + ) : error ? ( + + 데이터를 불러올 수 없어요. + {error} + + ) : books.length === 0 ? ( 아직 순위가 집계되지 않았어요. 조금만 기다려주세요! ) : ( - {dummyBooks.map((book, index) => ( - - {index + 1}. - + {books.map(book => ( + handleBookClick(book.isbn)}> + {book.rank}. + {book.title} ))} @@ -106,6 +113,12 @@ const BookItem = styled.li` align-items: center; padding: 12px 0; border-bottom: 1px solid ${colors.darkgrey.dark}; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${colors.darkgrey.main}; + } `; const Rank = styled.span` @@ -151,3 +164,13 @@ const SubText = styled.div` color: ${colors.grey[100]}; font-weight: ${typography.fontWeight.regular}; `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; + font-size: ${typography.fontSize.base}; + color: ${colors.grey[200]}; + font-weight: ${typography.fontWeight.regular}; +`; diff --git a/src/components/today-words/MessageInput.tsx b/src/components/today-words/MessageInput.tsx index e79d6010..4ee9bc19 100644 --- a/src/components/today-words/MessageInput.tsx +++ b/src/components/today-words/MessageInput.tsx @@ -19,6 +19,7 @@ interface MessageInputProps { isReplying?: boolean; onCancelReply?: () => void; nickname?: string; + disabled?: boolean; } const MessageInput = ({ @@ -29,11 +30,13 @@ const MessageInput = ({ isReplying = false, onCancelReply, nickname, + disabled = false, }: MessageInputProps) => { const inputRef = useRef(null); const [isComposing, setIsComposing] = useState(false); const handleInputChange = (e: React.ChangeEvent) => { + if (disabled) return; onChange(e.target.value); if (inputRef.current) { @@ -43,7 +46,7 @@ const MessageInput = ({ }; const handleSend = () => { - if (!value.trim()) return; + if (!value.trim() || disabled) return; onSend(); onChange(''); if (inputRef.current) { @@ -52,6 +55,7 @@ const MessageInput = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; if (e.key === 'Enter' && !e.shiftKey && !isComposing) { e.preventDefault(); handleSend(); @@ -83,15 +87,28 @@ const MessageInput = ({ setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} + onCompositionStart={() => !disabled && setIsComposing(true)} // disabled일 때는 composing 상태 변경 안함 + onCompositionEnd={() => !disabled && setIsComposing(false)} rows={1} + disabled={disabled} + style={{ + opacity: disabled ? 0.6 : 1, + cursor: disabled ? 'not-allowed' : 'text', + }} /> - + 전송 diff --git a/src/data/postData.ts b/src/data/postData.ts index e65d867a..94c504d1 100644 --- a/src/data/postData.ts +++ b/src/data/postData.ts @@ -20,6 +20,7 @@ export const mockPosts: PostData[] = [ isSaved: false, isLiked: true, isPublic: true, + isWriter: false, }, { feedId: 56, @@ -39,6 +40,7 @@ export const mockPosts: PostData[] = [ isSaved: true, isLiked: false, isPublic: false, + isWriter: false, }, { feedId: 58, @@ -58,6 +60,7 @@ export const mockPosts: PostData[] = [ isSaved: false, isLiked: false, isPublic: true, + isWriter: false, }, ]; @@ -80,6 +83,7 @@ export const mockFeedPost: FeedPostProps = { isSaved: true, isLiked: false, isPublic: true, + isWriter: false, }; // 📌 댓글/대댓글(Mock) @@ -97,6 +101,7 @@ export const mockCommentList: ReplyDataProps[] = [ likeCount: 1, isLike: false, isDeleted: false, + isWriter: false, replyList: [ { parentCommentCreatorNickname: 'User31', @@ -110,6 +115,7 @@ export const mockCommentList: ReplyDataProps[] = [ content: '맞아요, 저도 너무 좋았어요!맞아요, 저도 너무 좋았어요!', likeCount: 2, isLike: false, + isWriter: false, }, { parentCommentCreatorNickname: 'User35', @@ -123,6 +129,7 @@ export const mockCommentList: ReplyDataProps[] = [ content: '추천 감사합니다!', likeCount: 123, isLike: true, + isWriter: false, }, ], }, @@ -139,5 +146,6 @@ export const mockCommentList: ReplyDataProps[] = [ isLike: true, replyList: [], isDeleted: false, + isWriter: false, }, ]; diff --git a/src/hooks/useInfiniteCarousel.ts b/src/hooks/useInfiniteCarousel.ts index 50afb840..47c04504 100644 --- a/src/hooks/useInfiniteCarousel.ts +++ b/src/hooks/useInfiniteCarousel.ts @@ -3,7 +3,7 @@ import type { Group } from '../components/group/MyGroupBox'; const CLONE_COUNT = 10; -export function useInfiniteCarousel(groups: Group[]) { +export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: number }) { const scrollRef = useRef(null); const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const [current, setCurrent] = useState(0); @@ -14,6 +14,8 @@ export function useInfiniteCarousel(groups: Group[]) { const middleIndex = useMemo(() => Math.floor(infiniteGroups.length / 2), [infiniteGroups]); + const scaleAmount = options?.scaleAmount ?? 0.17; + const handleScroll = useCallback(() => { const container = scrollRef.current; if (!container) return; @@ -28,7 +30,7 @@ export function useInfiniteCarousel(groups: Group[]) { if (!card) return; const cardCenter = card.offsetLeft + card.offsetWidth / 2; const distance = Math.abs(center - (cardCenter - scrollLeft)); - const scale = Math.max(0.83, 1 - (distance / center) * 0.17); + const scale = Math.max(0.83, 1 - (distance / center) * scaleAmount); card.style.transform = `scale(${scale})`; if (distance < minDist) { @@ -49,7 +51,7 @@ export function useInfiniteCarousel(groups: Group[]) { container.scrollLeft = left; } } - }, [groups.length, middleIndex]); + }, [groups.length, middleIndex, scaleAmount]); useEffect(() => { const container = scrollRef.current; diff --git a/src/hooks/useOAuthToken.ts b/src/hooks/useOAuthToken.ts index 2b58f1d1..9d6f63d5 100644 --- a/src/hooks/useOAuthToken.ts +++ b/src/hooks/useOAuthToken.ts @@ -18,7 +18,7 @@ export const useOAuthToken = () => { // 서버에 토큰 발급 요청 apiClient - .post('/api/set-cookie', { loginTokenKey }, { withCredentials: true }) + .post('/auth/set-cookie', { loginTokenKey }, { withCredentials: true }) .then(response => { console.log('✅ 토큰 발급 성공:', response.data); // URL에서 code 파라미터 제거 @@ -28,8 +28,15 @@ export const useOAuthToken = () => { .catch(error => { console.error('❌ 토큰 발급 실패:', error); // 에러 발생 시 로그인 페이지로 이동 - navigate('/'); + // navigate('/'); + console.log('💡 임시 토큰을 사용하여 계속 진행합니다.'); + // 에러 발생 시에도 임시 토큰으로 계속 진행 + // URL에서 code 파라미터 제거 + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); }); + } else if (!loginTokenKey) { + console.log('🔑 loginTokenKey가 없습니다. 임시 토큰을 사용합니다.'); } }, [isTokenRequested, navigate]); diff --git a/src/pages/Guide.tsx b/src/pages/Guide.tsx new file mode 100644 index 00000000..015b7c53 --- /dev/null +++ b/src/pages/Guide.tsx @@ -0,0 +1,327 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import styled from '@emotion/styled'; +import { colors, typography } from '@/styles/global/global'; + +// Import guide images +import guide1 from '@/assets/signup/guide1.svg'; +import guide2 from '@/assets/signup/guide2.svg'; +import guide3 from '@/assets/signup/guide3.svg'; +import guide4 from '@/assets/signup/guide4.svg'; +import guide5 from '@/assets/signup/guide5.svg'; +import guide6 from '@/assets/signup/guide6.svg'; + +interface GuideStep { + id: number; + title: string; + description: string; + image: string; +} + +const Guide = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [currentStep, setCurrentStep] = useState(0); + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + const containerRef = useRef(null); + + // SignupGenre에서 전달받은 닉네임 + const nickname = location.state?.nickname || '사용자'; + const aliasName = location.state?.aliasName || '독서가'; + // 터치 시작 + const handleTouchStart = (e: React.TouchEvent) => { + setTouchStart(e.targetTouches[0].clientX); + }; + + // 터치 종료 + const handleTouchEnd = (e: React.TouchEvent) => { + setTouchEnd(e.changedTouches[0].clientX); + }; + + // 슬라이드 처리 + useEffect(() => { + if (!touchStart || !touchEnd) return; + + const distance = touchStart - touchEnd; + const isLeftSwipe = distance > 50; // 왼쪽으로 50px 이상 스와이프 + const isRightSwipe = distance < -50; // 오른쪽으로 50px 이상 스와이프 + + if (isLeftSwipe && currentStep < guideSteps.length - 1) { + // 왼쪽으로 스와이프: 다음 단계 + setCurrentStep(currentStep + 1); + } else if (isRightSwipe && currentStep > 0) { + // 오른쪽으로 스와이프: 이전 단계 + setCurrentStep(currentStep - 1); + } + + // 터치 상태 초기화 + setTouchStart(null); + setTouchEnd(null); + }, [touchStart, touchEnd, currentStep]); + + // 마우스 드래그 기능 + const [mouseStart, setMouseStart] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + const handleMouseDown = (e: React.MouseEvent) => { + setMouseStart(e.clientX); + setIsDragging(true); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging || mouseStart === null) return; + + const distance = mouseStart - e.clientX; + if (Math.abs(distance) > 50) { + if (distance > 0 && currentStep < guideSteps.length - 1) { + // 왼쪽으로 드래그: 다음 단계 + setCurrentStep(currentStep + 1); + } else if (distance < 0 && currentStep > 0) { + // 오른쪽으로 드래그: 이전 단계 + setCurrentStep(currentStep - 1); + } + setIsDragging(false); + setMouseStart(null); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setMouseStart(null); + }; + + const guideSteps: GuideStep[] = [ + { + id: 1, + title: '피드', + description: '피드에서 책과 독서에 대한 생각을
자유롭게 나누어보세요!', + image: guide1, + }, + { + id: 2, + title: '피드', + description: + "칭호를 통해 내 독서 취향을 드러내고,
마음에 드는 유저를 '띱'하고 감상을 공유해보세요!", + image: guide2, + }, + { + id: 3, + title: '모임', + description: '모임방에서는 글은 물론 투표 기능을 통해
감상과 의견을 나눌 수 있어요.', + image: guide3, + }, + { + id: 4, + title: '모임', + description: + '읽고 싶은 책으로 나만의 독서 모임을 만들고,
독서메이트와 함께 기록을 나눌 수 있어요. ', + image: guide4, + }, + { + id: 5, + title: 'Thip+', + description: + '기록은 자유롭게, 감상은 방해없이.
읽지 않은 페이지에 대한 기록은
블라인드되어 스포일러 걱정없이 몰입할 수 있어요.', + image: guide5, + }, + { + id: 6, + title: 'Thip+', + description: "모임방의 인상깊은 기록을
'핀하기'로 피드에 다시 공유해보세요.", + image: guide6, + }, + ]; + + const handleNext = () => { + // active 상태일 때만 다음 단계로 이동 + if (currentStep < guideSteps.length - 1) { + setCurrentStep(currentStep + 1); + } else { + // 마지막 단계에서 완료 처리 - SignupDone으로 닉네임 전달 + navigate('/signup/done', { + state: { + nickName: nickname, + aliasName: aliasName, + }, + }); + } + }; + + const handleSkip = () => { + navigate('/signup/done', { + state: { + nickName: nickname, + aliasName: aliasName, + }, + }); + }; + + const handleIndicatorClick = (step: number) => { + setCurrentStep(step); + }; + + return ( + +
e.stopPropagation()}> +
+ 다음 +
+
+ + + + <TitleText>{guideSteps[currentStep].title}</TitleText> + <Description dangerouslySetInnerHTML={{ __html: guideSteps[currentStep].description }} /> + + + {guideSteps[currentStep].title} + + + + {guideSteps.map((_, index) => ( + handleIndicatorClick(index)} + /> + ))} + + 건너뛰기 + + +
+ ); +}; + +export default Guide; + +const Container = styled.div<{ isDragging?: boolean }>` + display: flex; + flex-direction: column; + min-width: 320px; + max-width: 767px; + min-height: 100vh; + margin: 0 auto; + align-items: center; + justify-content: center; + background: ${colors.black.main}; + color: ${colors.white}; + user-select: none; /* 텍스트 선택 방지 */ + cursor: ${({ isDragging }) => (isDragging ? 'grabbing' : 'grab')}; +`; + +const Header = styled.div<{ active: boolean }>` + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1100; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + max-width: 766px; + margin: 0 auto; + padding: 16px 20px; + background-color: ${colors.black.main}; + + .next-button { + width: 49px; + height: 28px; + padding: 4px 12px; + border-radius: 20px; + background-color: ${colors.purple.main}; + color: ${colors.white}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 20px; + text-align: center; + margin-left: auto; + cursor: pointer; + } +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 66px 20px 0 20px; + gap: 20px; + flex: 1; + min-height: calc(100vh - 66px); +`; + +const Title = styled.div` + display: flex; + flex-direction: column; + align-items: center; + height: 135px; + gap: 20px; +`; + +const TitleText = styled.div` + color: ${colors.white}; + font-size: ${typography.fontSize['xl']}; + font-weight: ${typography.fontWeight.bold}; + line-height: 24px; +`; + +const Description = styled.div` + color: ${colors.grey[100]}; + font-size: ${typography.fontSize.base}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + text-align: center; +`; + +const MockupContainer = styled.div` + img { + width: 220px; + height: 453.052px; + } +`; + +const BottomSection = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: column; + gap: 20px; + min-width: 320px; + max-width: 767px; + padding: 0 20px; +`; + +const Indicators = styled.div` + display: flex; + justify-content: center; + left: 50%; +`; + +const Indicator = styled.div<{ active: boolean }>` + width: 4px; + height: 4px; + background: ${({ active }) => (active ? colors.white : colors.grey[300])}; + border-radius: 50%; + margin: 0 6px; + cursor: pointer; +`; + +const SkipButton = styled.div` + color: ${colors.grey[200]}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + line-height: normal; + cursor: pointer; +`; diff --git a/src/pages/feed/MyFeedPage.tsx b/src/pages/feed/MyFeedPage.tsx new file mode 100644 index 00000000..52186562 --- /dev/null +++ b/src/pages/feed/MyFeedPage.tsx @@ -0,0 +1,92 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; +import NavBar from '../../components/common/NavBar'; +import TitleHeader from '@/components/common/TitleHeader'; +import writefab from '../../assets/common/writefab.svg'; +import leftArrow from '../../assets/common/leftArrow.svg'; +import OtherFeed from '@/components/feed/OtherFeed'; +import { getOtherFeed, type OtherFeedItem } from '@/api/feeds/getOtherFeed'; +import { getOtherProfile } from '@/api/users/getOtherProfile'; +import type { OtherProfileData } from '@/types/profile'; + +const Container = styled.div` + min-width: 320px; + max-width: 767px; + margin: 0 auto; +`; + +const MyFeedPage = () => { + const navigate = useNavigate(); + const { userId } = useParams<{ userId: string }>(); + const [feedData, setFeedData] = useState([]); + const [profileData, setProfileData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const handleBackClick = () => { + navigate(-1); + }; + + // 다른 사용자 피드 및 프로필 데이터 로드 + useEffect(() => { + const loadOtherData = async () => { + if (!userId) { + setError('사용자 ID가 없습니다.'); + setLoading(false); + return; + } + + try { + setLoading(true); + + const [feedResponse, profileResponse] = await Promise.all([ + getOtherFeed(Number(userId)), + getOtherProfile(Number(userId)), + ]); + + console.log('🔍 MyFeedPage - Profile Response:', profileResponse.data); + console.log('🔍 MyFeedPage - isWriter 값:', profileResponse.data.isWriter); + + setFeedData(feedResponse.data.feedList); + setProfileData(profileResponse.data); + setError(null); + } catch (err) { + console.error('다른 사용자 데이터 로드 실패:', err); + setError('사용자 정보를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + loadOtherData(); + }, [userId]); + + if (loading) { + return <>; + } + + if (error) { + return <>; + } + + return ( + + } + onLeftClick={handleBackClick} + /> + + + + ); +}; + +export default MyFeedPage; diff --git a/src/pages/feed/OtherFeedPage.tsx b/src/pages/feed/OtherFeedPage.tsx index db3fab8a..ea87a156 100644 --- a/src/pages/feed/OtherFeedPage.tsx +++ b/src/pages/feed/OtherFeedPage.tsx @@ -80,6 +80,7 @@ const OtherFeedPage = () => { posts={feedData} isMyFeed={false} profileData={profileData} + showFollowButton={!profileData?.isWriter} // isWriter가 true면 팔로우 버튼 숨김 />
diff --git a/src/pages/feed/UserSearch.tsx b/src/pages/feed/UserSearch.tsx index ded4c001..6dc819ee 100644 --- a/src/pages/feed/UserSearch.tsx +++ b/src/pages/feed/UserSearch.tsx @@ -9,6 +9,8 @@ import leftArrow from '../../assets/common/leftArrow.svg'; import { UserSearchResult } from './UserSearchResult'; import { useNavigate } from 'react-router-dom'; import { useUserSearch } from '@/hooks/useUserSearch'; +import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; +import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; const UserSearch = () => { const navigate = useNavigate(); @@ -23,13 +25,27 @@ const UserSearch = () => { isFinalized: isSearched, }); - const [recentSearches, setRecentSearches] = useState([ - '닉네임', - '작가', - '하위', - 'Thip', - '책벌레', - ]); + const [recentSearches, setRecentSearches] = useState([]); + + const fetchRecentSearches = async () => { + try { + const response = await getRecentSearch('USER'); + + if (response.isSuccess) { + setRecentSearches(response.data.recentSearchList); + } else { + console.error('최근 검색어 조회 실패:', response.message); + setRecentSearches([]); + } + } catch (error) { + console.error('최근 검색어 조회 오류:', error); + setRecentSearches([]); + } + }; + + useEffect(() => { + fetchRecentSearches(); + }, []); const handleChange = (value: string) => { setSearchTerm(value); @@ -41,14 +57,29 @@ const UserSearch = () => { if (!term.trim()) return; setIsSearching(true); setIsSearched(true); - setRecentSearches(prev => { - const filtered = prev.filter(t => t !== term); - return [term, ...filtered].slice(0, 5); - }); }; - const handleDelete = (recentSearch: string) => { - setRecentSearches(prev => prev.filter(t => t !== recentSearch)); + const handleDelete = async (recentSearchId: number) => { + try { + const userId = 1; // 임시 userId + + const response = await deleteRecentSearch(recentSearchId, userId); + + if (response.isSuccess) { + setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); + } else { + console.error('최근 검색어 삭제 실패:', response.message); + } + } catch (error) { + console.error('최근 검색어 삭제 오류:', error); + } + }; + + const handleDeleteWrapper = (searchTerm: string) => { + const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); + if (recentSearchItem) { + handleDelete(recentSearchItem.recentSearchId); + } }; const handleRecentSearchClick = (recentSearch: string) => { @@ -108,8 +139,8 @@ const UserSearch = () => { ) : ( <> item.searchTerm)} + handleDelete={handleDeleteWrapper} handleRecentSearchClick={handleRecentSearchClick} /> diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx index d6e975f7..e405b9ae 100644 --- a/src/pages/group/CreateGroup.tsx +++ b/src/pages/group/CreateGroup.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import BookSearchBottomSheet from '../../components/common/BookSearchBottomSheet/BookSearchBottomSheet'; import BookSelectionSection from '../../components/creategroup/BookSelectionSection'; @@ -24,7 +24,21 @@ interface Book { const CreateGroup = () => { const navigate = useNavigate(); - const [selectedBook, setSelectedBook] = useState(null); + const location = useLocation(); + + function convertBookInfoToBook(bookInfo: Book): Book | null { + if (!bookInfo) return null; + return { + title: bookInfo.title, + author: bookInfo.author, + cover: bookInfo.cover, + isbn: bookInfo.isbn, + }; + } + + const [selectedBook, setSelectedBook] = useState( + convertBookInfoToBook(location.state?.selectedBook ?? location.state?.bookInfo), + ); const [selectedGenre, setSelectedGenre] = useState(''); const [roomTitle, setRoomTitle] = useState(''); const [roomDescription, setRoomDescription] = useState(''); @@ -137,9 +151,8 @@ const CreateGroup = () => { if (isSuccessful) { // 성공 시 모집 중인 방 상세 페이지로 이동 - navigate('/group/detail', { + navigate(`/group/detail/${response.data.roomId}`, { replace: true, - state: { roomId: response.data.roomId }, }); } else { alert(`방 생성에 실패했습니다: ${response.message} (코드: ${response.code})`); diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 00adab86..415fcdf6 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -6,121 +6,74 @@ import { MyGroupBox } from '../../components/group/MyGroupBox'; import Blank from '@/components/common/Blank'; import styled from '@emotion/styled'; import { RecruitingGroupCarousel, type Section } from '@/components/group/RecruitingGroupCarousel'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { MyGroupModal } from '@/components/group/MyGroupModal'; import CompletedGroupModal from '@/components/group/CompletedGroupModal'; import { useNavigate } from 'react-router-dom'; import makegroupfab from '../../assets/common/makegroupfab.svg'; +import { getRoomsByCategory, type RoomItem } from '@/api/rooms/getRoomsByCategory'; -const dummyMyGroups: GroupType[] = [ - { - id: '1', - title: '호르몬 체인지 완독하는 방', - participants: 22, - userName: 'hoho', - progress: 40, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - progress: 0, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - }, - { - id: '3', - title: '일본 소설 좋아하는 사람들', - userName: 'hoho3', - participants: 30, - progress: 100, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - }, -]; - -const dummyRecruitingGroups: GroupType[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, -]; - -const sections: Section[] = [ - { title: '마감 임박한 독서 모임방', groups: dummyRecruitingGroups }, - { title: '인기 있는 독서 모임방', groups: dummyRecruitingGroups }, - { title: '인플루언서·작가 독서 모임방', groups: dummyRecruitingGroups }, -]; +const convertRoomItemToGroup = ( + room: RoomItem, + category: string, + listType: 'deadline' | 'popular', +): GroupType => ({ + id: `${room.roomId}-${category}-${listType}`, + title: room.roomName, + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.bookImageUrl, + deadLine: Math.ceil( + (new Date(room.deadlineDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24), + ), + genre: category, +}); const Group = () => { const navigate = useNavigate(); const [isMyGroupModalOpen, setIsMyGroupModalOpen] = useState(false); const [isCompletedGroupModalOpen, setIsCompletedGroupModalOpen] = useState(false); + const [sections, setSections] = useState([ + { title: '마감 임박한 독서 모임방', groups: [] }, + { title: '인기 있는 독서 모임방', groups: [] }, + ]); + + const fetchAllRoomsData = async () => { + try { + const categories = ['문학', '인문학', '사회과학', '과학·IT', '예술']; + const deadlineRoomsData: GroupType[] = []; + const popularRoomsData: GroupType[] = []; + + for (const category of categories) { + const response = await getRoomsByCategory(category); + if (response.isSuccess) { + const deadlineGroups = response.data.deadlineRoomList.map(room => + convertRoomItemToGroup(room, category, 'deadline'), + ); + const popularGroups = response.data.popularRoomList.map(room => + convertRoomItemToGroup(room, category, 'popular'), + ); + deadlineRoomsData.push(...deadlineGroups); + popularRoomsData.push(...popularGroups); + } + } + + setSections([ + { title: '마감 임박한 독서 모임방', groups: deadlineRoomsData }, + { title: '인기 있는 독서 모임방', groups: popularRoomsData }, + ]); + } catch (error) { + console.error('방 목록 조회 오류:', error); + setSections([ + { title: '마감 임박한 독서 모임방', groups: [] }, + { title: '인기 있는 독서 모임방', groups: [] }, + ]); + } + }; + + useEffect(() => { + fetchAllRoomsData(); + }, []); const openMyGroupModal = () => setIsMyGroupModalOpen(true); const closeMyGroupModal = () => setIsMyGroupModalOpen(false); @@ -129,15 +82,16 @@ const Group = () => { const closeCompletedGroupModal = () => setIsCompletedGroupModalOpen(false); const handleSearchBarClick = () => { - navigate('/groupsearch'); + navigate('/group/search'); }; + return ( {isMyGroupModalOpen && } {isCompletedGroupModalOpen && } - + diff --git a/src/pages/groupDetail/GroupDetail.styled.ts b/src/pages/groupDetail/GroupDetail.styled.ts index c34757ea..39c9486e 100644 --- a/src/pages/groupDetail/GroupDetail.styled.ts +++ b/src/pages/groupDetail/GroupDetail.styled.ts @@ -176,13 +176,14 @@ export const BookDetails = styled.div` font-weight: ${typography.fontWeight.medium}; gap: 20px; color: ${colors.white}; - margin: auto 0; + margin-top: 8px; `; export const BookIntro = styled.div` > p { margin-top: 4px; color: ${colors.grey[200]}; + font-size: ${typography.fontSize['2xs']}; } `; @@ -224,4 +225,5 @@ export const BottomButton = styled.button` font-weight: ${typography.fontWeight.semibold}; border: none; z-index: 10; + cursor: pointer; `; diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index e7d6d5eb..cd1d1dcb 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { Wrapper, TopBackground, @@ -26,52 +27,162 @@ import { BottomButton, } from './GroupDetail.styled'; import leftArrow from '../../assets/common/leftArrow.svg'; -import moreIcon from '../../assets/common/more.svg'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { IconButton } from '@/components/common/IconButton'; -import { mockGroupDetail } from '../../mocks/groupDetail.mock'; import lockIcon from '../../assets/group/lock.svg'; import calendarIcon from '../../assets/group/calendar.svg'; import peopleIcon from '../../assets/common/darkPeople.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import { GroupCard } from '@/components/group/GroupCard'; +import { + getRoomDetail, + type RoomDetailResponse, + type RecommendRoom, +} from '@/api/rooms/getRoomDetail'; +import { postJoinRoom } from '@/api/rooms/postJoinRoom'; +import { postCloseRoom } from '@/api/rooms/postCloseRoom'; +import type { Group } from '@/components/group/MyGroupBox'; const GroupDetail = () => { - const { - title, - isPrivate, - introduction, - activityPeriod, - members, - ddayText, - genre, - book, - recommendations, - } = mockGroupDetail; - + const { roomId } = useParams<{ roomId: string }>(); const navigate = useNavigate(); + const [roomData, setRoomData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [isJoining, setIsJoining] = useState(null); + const handleBackButton = () => { navigate(-1); }; - const handleMoreButton = () => {}; + const convertRecommendRoomToGroup = (room: RecommendRoom): Group => { + return { + id: room.roomId.toString(), + title: room.roomName, + userName: '', + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.roomImageUrl, + deadLine: 0, + genre: '', + isOnGoing: true, + }; + }; + + const calculateDday = (recruitEndDate: string): string => { + const today = new Date(); + const endDate = new Date(recruitEndDate); + const diffTime = endDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + console.log(endDate); + if (diffDays < 0) return '모집 종료'; + if (diffDays === 0) return '오늘 마감'; + return `${diffDays}일 남음`; + }; + + useEffect(() => { + const fetchRoomDetail = async () => { + if (!roomId) return; + + try { + setIsLoading(true); + setError(null); + + const response = await getRoomDetail(Number(roomId)); + console.log(response); + + if (response.isSuccess) { + setRoomData(response.data); + } else { + setError(response.message); + } + } catch (error) { + console.error('방 상세 정보 조회 실패:', error); + setError('방 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchRoomDetail(); + }, [roomId]); + + useEffect(() => { + if (roomData) { + setIsJoining(roomData.isJoining); + } + }, [roomData]); + + if (isLoading) { + return
로딩 중...
; + } + + if (error || !roomData) { + return
에러: {error}
; + } + + const { + roomName, + isPublic, + roomDescription, + progressStartDate, + progressEndDate, + memberCount, + recruitCount, + recruitEndDate, + category, + bookTitle, + authorName, + bookDescription, + bookImageUrl, + recommendRooms, + } = roomData; + + const handleBookSectionClick = () => { + const isbn = roomData?.isbn; + if (isbn) { + navigate(`/search/book/${isbn}`); + } + }; + + const handleRecommendGroupCardClick = (roomId: number | string) => { + navigate(`/group/detail/${roomId}`); + }; + + const handleBottomButtonClick = async () => { + if (roomData.isHost) { + try { + await postCloseRoom(Number(roomId)); + } catch { + alert('네트워크 오류 또는 서버 오류'); + } + return; + } + const type = isJoining ? 'cancel' : 'join'; + try { + await postJoinRoom(Number(roomId), type); + } catch { + alert('네트워크 오류 또는 서버 오류'); + } + }; return ( - +
-
- {title} {isPrivate && 자물쇠 아이콘} + {roomName} {!isPublic && 자물쇠 아이콘}
소개글

- {introduction} + {roomDescription}
@@ -79,7 +190,7 @@ const GroupDetail = () => { 모임 활동기간 - {activityPeriod.start} ~ {activityPeriod.end} + {progressStartDate} ~ {progressEndDate} @@ -87,33 +198,33 @@ const GroupDetail = () => { 참여 중인 독서메이트 - {members.current} - / {members.max}명 + {memberCount} + / {recruitCount}명 - 모집 {ddayText} + 모집 {calculateDday(recruitEndDate)} - 장르 {genre} + 장르 {category}
- + -

{book.title}

- +

{bookTitle}

+
- + -
{book.author}
+
{authorName}
도서 소개
-

{book.description}

+

{bookDescription}

@@ -121,18 +232,21 @@ const GroupDetail = () => { 이런 모임방은 어때요? - {recommendations.map(group => ( + {recommendRooms.map(room => ( handleRecommendGroupCardClick(room.roomId)} /> ))} - 참여하기 + + {roomData.isHost ? '모집 마감하기' : isJoining ? '참여 취소하기' : '참여하기'} +
); }; diff --git a/src/pages/groupDetail/ParticipatedGroupDetail.tsx b/src/pages/groupDetail/ParticipatedGroupDetail.tsx index 0b949ae0..c13588b1 100644 --- a/src/pages/groupDetail/ParticipatedGroupDetail.tsx +++ b/src/pages/groupDetail/ParticipatedGroupDetail.tsx @@ -1,3 +1,5 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { TopBackground, Header, @@ -25,29 +27,63 @@ import CommentSection from '../../components/group/CommentSection'; import HotTopicSection from '../../components/group/HotTopicSection'; import GroupBookSection from '../../components/group/GroupBookSection'; import GroupActionBottomSheet from '../../components/group/GroupActionBottomSheet'; -import type { Poll } from '../../components/group/HotTopicSection'; import { usePopupActions } from '@/hooks/usePopupActions'; +import { + getRoomPlaying, + type RoomPlayingResponse, + convertVotesToPolls, + type Poll, +} from '@/api/rooms/getRoomPlaying'; import rightChevron from '../../assets/group/right-chevron.svg'; - import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; -import { useNavigate } from 'react-router-dom'; import { IconButton } from '@/components/common/IconButton'; -import { mockGroupDetail } from '../../mocks/groupDetail.mock'; import lockIcon from '../../assets/group/lock.svg'; import calendarIcon from '../../assets/group/calendar.svg'; import peopleIcon from '../../assets/common/darkPeople.svg'; -import { useState } from 'react'; +import styled from '@emotion/styled'; const ParticipatedGroupDetail = () => { - const { title, isPrivate, introduction, activityPeriod, members, genre, book } = mockGroupDetail; const { openConfirm } = usePopupActions(); - const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + + // API 상태 관리 + const [roomData, setRoomData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // UI 상태 관리 const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); - // 모임방 생성자 여부 (실제로는 API에서 받아와야 함) - const [isGroupOwner] = useState(false); // true면 생성자, false면 참여자 + // API 호출 + useEffect(() => { + const fetchRoomDetail = async () => { + if (!roomId) { + setError('방 ID가 없습니다.'); + setLoading(false); + return; + } + + try { + setLoading(true); + const response = await getRoomPlaying(parseInt(roomId)); + + if (response.isSuccess) { + setRoomData(response); + } else { + setError(response.message); + } + } catch (err) { + setError('방 정보를 불러오는 중 오류가 발생했습니다.'); + console.error('방 상세 정보 조회 오류:', err); + } finally { + setLoading(false); + } + }; + + fetchRoomDetail(); + }, [roomId]); const handleBackButton = () => { navigate(-1); @@ -67,7 +103,6 @@ const ParticipatedGroupDetail = () => { disc: '방을 삭제하게 되면\n독서메이트들과의 추억이 사라집니다.', onConfirm: () => { console.log('방 삭제 확정'); - // 실제 삭제 API 호출 후 홈으로 이동 navigate('/group'); }, }); @@ -79,107 +114,96 @@ const ParticipatedGroupDetail = () => { disc: '방을 나가시게 되면\n독서메이트들과의 추억이 사라집니다.', onConfirm: () => { console.log('방 나가기 확정'); - // 실제 나가기 API 호출 후 홈으로 이동 navigate('/group'); }, }); }; const handleReportGroup = () => { - // 방 신고하기 로직 console.log('방 신고하기'); - // 실제로는 신고 모달이나 페이지로 이동 }; const handleRecordSectionClick = () => { - navigate('/memory'); + navigate(`/memory/${roomId}`); }; const handleCommentSectionClick = () => { - navigate('/today-words'); + navigate(`/today-words/${roomId}`); }; const handleHotTopicSectionClick = () => { - // 뜨거운 감자 전체 페이지로 이동 - navigate('/memory'); // 또는 투표 전체 리스트 페이지 + navigate(`/memory/${roomId}`); }; const handleBookSectionClick = () => { - navigate(`/book/123`); + if (roomData?.data.isbn) { + navigate(`/book/${roomData.data.isbn}`); + } }; - // 투표 클릭 시 해당 페이지의 기록장으로 이동 const handlePollClick = (pageNumber: number) => { - // 해당 투표가 위치한 페이지 번호로 필터를 씌운 기록장 화면으로 이동 - navigate(`/memory?page=${pageNumber}&filter=poll`); + navigate(`/memory/${roomId}?page=${pageNumber}&filter=poll`); }; const handleMembersClick = () => { - navigate('/group/members'); // 또는 실제 독서메이트 페이지 경로 + navigate(`/group/${roomId}/members`); + }; + + // 로딩 상태 + if (loading) { + return ( + + 로딩 중... + + ); + } + + // 에러 상태 + if (error || !roomData) { + return ( + + {error || '데이터를 불러올 수 없습니다.'} + + ); + } + + const { data } = roomData; + + // API 데이터를 컴포넌트에 맞게 변환 + const polls: Poll[] = convertVotesToPolls(data.currentVotes); + const hasPolls = polls.length > 0; + + // 날짜 포맷팅 (YYYY-MM-DD -> YYYY.MM.DD) + const formatDate = (dateString: string) => { + return dateString.replace(/-/g, '.'); }; - // 모킹 데이터 - const recordData = { - currentPage: 1, - progress: 30, + // 장르에 따른 배경색 결정 (카테고리 컬러 사용) + const getGenreForBackground = () => { + // categoryColor를 사용하거나 기본값으로 장르명 사용 + return data.category; }; + // 댓글 섹션 메시지 const commentData = { message: '모임방 멤버들과 간단한 인사를 나눠보세요!', }; - // 투표 데이터 (투표 결과 없이 질문과 선택지만) - const mockPolls: Poll[] = [ - { - id: '1', - question: '3연에 나오는 심장은 무엇을 의미하는 걸까요?', - options: [ - { id: '1', text: '김땡땡' }, - { id: '2', text: '김땡땡' }, - ], - pageNumber: 456, // 해당 투표가 위치한 페이지 - }, - { - id: '2', - question: '또 다른 투표 질문입니다', - options: [ - { id: '1', text: '선택지 1' }, - { id: '2', text: '선택지 2' }, - { id: '3', text: '선택지 3' }, - ], - pageNumber: 123, - }, - { - id: '3', - question: '세 번째 투표입니다', - options: [ - { id: '1', text: 'A 답변' }, - { id: '2', text: 'B 답변' }, - ], - pageNumber: 789, - }, - ]; - - // 투표가 없을 때 테스트하려면 이걸 사용 - // const mockPolls: Poll[] = []; - - const hasPolls = mockPolls.length > 0; - return ( - +
- {title} {isPrivate && 자물쇠 아이콘} + {data.roomName} {!data.isPublic && 자물쇠 아이콘}
소개글

- {introduction} + {data.roomDescription}
@@ -187,7 +211,7 @@ const ParticipatedGroupDetail = () => { 모임 활동기간 - {activityPeriod.start} ~ {activityPeriod.end} + {formatDate(data.progressStartDate)} ~ {formatDate(data.progressEndDate)} @@ -201,7 +225,7 @@ const ParticipatedGroupDetail = () => { - {members.current} + {data.memberCount} 명 참여 중
@@ -210,24 +234,28 @@ const ParticipatedGroupDetail = () => { - 장르 {genre} + 장르 {data.category} - + { { ); }; +const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-grey-200); + font-size: var(--string-size-base, 16px); +`; + +const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-red); + font-size: var(--string-size-base, 16px); + text-align: center; + padding: 20px; +`; + export default ParticipatedGroupDetail; diff --git a/src/pages/groupMembers/GroupMembers.styled.ts b/src/pages/groupMembers/GroupMembers.styled.ts index 91944305..f12e9657 100644 --- a/src/pages/groupMembers/GroupMembers.styled.ts +++ b/src/pages/groupMembers/GroupMembers.styled.ts @@ -11,3 +11,32 @@ export const Wrapper = styled.div` margin: 0 auto; background-color: ${colors.black.main}; `; + +export const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-grey-200); + font-size: var(--string-size-base, 16px); +`; + +export const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-red); + font-size: var(--string-size-base, 16px); + text-align: center; + padding: 20px; +`; + +export const EmptyContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-grey-200); + font-size: var(--string-size-base, 16px); +`; diff --git a/src/pages/groupMembers/GroupMembers.tsx b/src/pages/groupMembers/GroupMembers.tsx index 5f15390e..a0886427 100644 --- a/src/pages/groupMembers/GroupMembers.tsx +++ b/src/pages/groupMembers/GroupMembers.tsx @@ -1,12 +1,57 @@ -import { useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import MemberList from '../../components/members/MemberList'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { mockMembers } from '../../mocks/members.mock'; -import { Wrapper } from './GroupMembers.styled'; +import { Wrapper, LoadingContainer, ErrorContainer, EmptyContainer } from './GroupMembers.styled'; +import { + getRoomMembers, + convertRoomMembersToMembers, + type Member, + type RoomMembersResponse, +} from '@/api/rooms/getRoomMembers'; const GroupMembers = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + + // API 상태 관리 + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // API 호출 + useEffect(() => { + const fetchMembers = async () => { + // roomId 우선 순위: URL 파라미터 > localStorage > 기본값 1 + const currentRoomId = roomId || localStorage.getItem('currentRoomId') || '1'; + + if (!currentRoomId) { + setError('방 ID를 찾을 수 없습니다.'); + setLoading(false); + return; + } + + try { + setLoading(true); + const response: RoomMembersResponse = await getRoomMembers(parseInt(currentRoomId)); + + if (response.isSuccess) { + const convertedMembers = convertRoomMembersToMembers(response.data.userList); + setMembers(convertedMembers); + } else { + setError(response.message); + } + } catch (err) { + setError('독서메이트 목록을 불러오는 중 오류가 발생했습니다.'); + console.error('독서메이트 조회 오류:', err); + } finally { + setLoading(false); + } + }; + + fetchMembers(); + }, [roomId]); const handleBackClick = () => { navigate(-1); @@ -14,9 +59,41 @@ const GroupMembers = () => { const handleMemberClick = (memberId: string) => { // 특정 사용자 페이지로 이동 - navigate(`/user/${memberId}`); + navigate(`/otherfeed/${memberId}`); }; + // 로딩 상태 + if (loading) { + return ( + <> + } + title="독서메이트" + onLeftClick={handleBackClick} + /> + + 로딩 중... + + + ); + } + + // 에러 상태 + if (error) { + return ( + <> + } + title="독서메이트" + onLeftClick={handleBackClick} + /> + + {error} + + + ); + } + return ( <> { onLeftClick={handleBackClick} /> - + {members.length > 0 ? ( + + ) : ( + 독서메이트가 없습니다. + )} ); diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index 447ed10d..1dae1663 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -3,40 +3,75 @@ import { Modal, Overlay } from '@/components/group/Modal.styles'; import leftArrow from '../../assets/common/leftArrow.svg'; import { useNavigate } from 'react-router-dom'; import SearchBar from '@/components/search/SearchBar'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import GroupSearchResult from '@/components/search/GroupSearchResult'; +import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; +import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; const GroupSearch = () => { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); + const [recentSearches, setRecentSearches] = useState([]); + const [isLoading, setIsLoading] = useState(false); - const [recentSearches, setRecentSearches] = useState([ - '딸기12', - '당근', - '수박245', - '참', - '메론1', - ]); + const fetchRecentSearches = async () => { + try { + setIsLoading(true); + const response = await getRecentSearch('ROOM'); - const handleSearch = (term: string) => { - setIsSearching(true); + if (response.isSuccess) { + setRecentSearches(response.data.recentSearchList); + } else { + console.error('최근 검색어 조회 실패:', response.message); + setRecentSearches([]); + } + } catch (error) { + console.error('최근 검색어 조회 오류:', error); + setRecentSearches([]); + } finally { + setIsLoading(false); + } + }; - setRecentSearches(prev => { - const filtered = prev.filter(t => t !== term); - return [term, ...filtered].slice(0, 5); - }); + useEffect(() => { + fetchRecentSearches(); + }, []); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleSearch = (_term: string) => { + setIsSearching(true); + // 검색 로직만 수행, 최근 검색어는 서버에서 관리 }; - const handleDelete = (recentSearch: string) => { - setRecentSearches(prev => prev.filter(t => t !== recentSearch)); + const handleDelete = async (recentSearchId: number) => { + try { + const userId = 1; // 임시 userId + + const response = await deleteRecentSearch(recentSearchId, userId); + + if (response.isSuccess) { + setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); + } else { + console.error('최근 검색어 삭제 실패:', response.message); + } + } catch (error) { + console.error('최근 검색어 삭제 오류:', error); + } }; const handleRecentSearchClick = (recentSearch: string) => { setSearchTerm(recentSearch); }; + const handleDeleteWrapper = (searchTerm: string) => { + const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); + if (recentSearchItem) { + handleDelete(recentSearchItem.recentSearchId); + } + }; + const handleBackButton = () => { navigate('/group'); }; @@ -60,8 +95,8 @@ const GroupSearch = () => { ) : ( item.searchTerm)} + handleDelete={handleDeleteWrapper} handleRecentSearchClick={handleRecentSearchClick} > )} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f34257dd..5cf16c67 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -18,6 +18,7 @@ import GroupSearch from './groupSearch/GroupSearch'; import Search from './search/Search'; import ApplyBook from './search/ApplyBook'; import OtherFeedPage from './feed/OtherFeedPage'; +import MyFeedPage from './feed/MyFeedPage'; import FollowerListPage from './feed/FollowerListPage'; import TodayWords from './today-words/TodayWords'; import SearchBook from './searchBook/SearchBook'; @@ -38,6 +39,7 @@ import EditPage from './mypage/EditPage'; import Notice from './notice/Notice'; import ParticipatedGroupDetail from './groupDetail/ParticipatedGroupDetail'; import GroupMembers from './groupMembers/GroupMembers'; +import Guide from './Guide'; const Router = () => { const router = createBrowserRouter( @@ -46,29 +48,32 @@ const Router = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> + } /> } /> } /> + } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 8c67bfc5..aeb6de6a 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -1,11 +1,13 @@ -import React, { useState, useMemo, useCallback } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useNavigate, useLocation, useParams } from 'react-router-dom'; import type { SortType } from '../../components/memory/SortDropdown'; import MemoryHeader from '../../components/memory/MemoryHeader/MemoryHeader'; import MemoryContent from '../../components/memory/MemoryContent/MemoryContent'; import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddButton'; import Snackbar from '../../components/common/Modal/Snackbar'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; +import { getMemoryPosts } from '../../api/memory/getMemoryPosts'; +import type { Post } from '../../types/memory'; export type RecordType = 'group' | 'my'; export type FilterType = 'page' | 'overall'; @@ -23,6 +25,7 @@ export interface Record { recordType?: 'page' | 'overall'; pollOptions?: PollOption[]; pageRange?: string; + isWriter?: boolean; } export interface PollOption { @@ -32,6 +35,30 @@ export interface PollOption { isHighest?: boolean; } +// API 포스트를 기존 Record 타입으로 변환하는 함수 +const convertPostToRecord = (post: Post): Record => { + return { + id: post.postId.toString(), + user: post.nickName, + userPoints: 132, + content: post.content, + likeCount: post.likeCount, + commentCount: post.commentCount, + timeAgo: post.postDate, + createdAt: new Date(), + type: post.postType === 'VOTE' ? 'poll' : 'text', + recordType: post.isOverview ? 'overall' : 'page', + pageRange: post.isOverview ? undefined : post.page.toString(), + isWriter: post.isWriter, + pollOptions: post.voteItems.map((item, index) => ({ + id: item.voteItemId.toString(), + text: item.itemName, + percentage: item.percentage, + isHighest: index === 0, + })), + }; +}; + const addRecordIfNotExists = (prevRecords: Record[], newRecord: Record) => { const exists = prevRecords.some(record => record.id === newRecord.id); if (exists) { @@ -43,6 +70,8 @@ const addRecordIfNotExists = (prevRecords: Record[], newRecord: Record) => { const Memory = () => { const navigate = useNavigate(); const location = useLocation(); + const { roomId } = useParams<{ roomId: string }>(); + const [activeTab, setActiveTab] = useState('group'); const [activeFilter, setActiveFilter] = useState(null); const [selectedSort, setSelectedSort] = useState('latest'); @@ -52,6 +81,9 @@ const Memory = () => { null, ); + // API 관련 상태 + const [error, setError] = useState(null); + // 업로드 프로그레스 상태 const [showUploadProgress, setShowUploadProgress] = useState(false); @@ -61,77 +93,101 @@ const Memory = () => { // 내 기록들을 별도로 관리 const [myRecords, setMyRecords] = useState([]); - // 그룹 기록들을 별도로 관리 (내가 작성한 것도 포함) - const [groupRecords, setGroupRecords] = useState([ - { - id: '1', - user: 'user.01', - userPoints: 132, - content: - '공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다.', - likeCount: 123, - commentCount: 123, - timeAgo: '12시간 전', - createdAt: new Date('2024-01-15T12:00:00'), - type: 'text', - recordType: 'page', - pageRange: '132', - }, - { - id: '2', - user: 'user.01', - userPoints: 132, - content: '공백 포함 글자 입력입니다.', - likeCount: 123, - commentCount: 123, - timeAgo: '12시간 전', - createdAt: new Date('2024-01-15T16:00:00'), - type: 'poll', - recordType: 'page', - pageRange: '132', - pollOptions: [ - { - id: '1.', - text: '김땡땡', - percentage: 90, - isHighest: true, - }, - { - id: '2.', - text: '김땡땡', - percentage: 10, - isHighest: false, - }, - ], - }, - ]); + // 그룹 기록들을 별도로 관리 + const [groupRecords, setGroupRecords] = useState([]); + + // API 데이터 로드 + const loadMemoryPosts = useCallback(async () => { + // roomId가 없으면 기본값 1 사용 또는 API 호출 스킵 + const currentRoomId = roomId || '1'; + + setError(null); + + try { + // 정렬 타입 변환 + let sortType: 'latest' | 'like' | 'comment' | undefined = undefined; + if (activeTab === 'group') { + if (selectedSort === 'latest') sortType = 'latest'; + else if (selectedSort === 'popular') sortType = 'like'; + else if (selectedSort === 'comments') sortType = 'comment'; + } + + // API 타입에 맞는 파라미터 구성 + const requestParams: { + roomId: number; + type: 'group' | 'mine'; + sort?: 'latest' | 'like' | 'comment'; + pageStart?: number | null; + pageEnd?: number | null; + isOverview?: boolean; + isPageFilter?: boolean; + cursor?: string | null; + } = { + roomId: parseInt(currentRoomId), + type: activeTab === 'my' ? 'mine' : 'group', + pageStart: selectedPageRange ? selectedPageRange.start : null, + pageEnd: selectedPageRange ? selectedPageRange.end : null, + isOverview: activeFilter === 'overall' ? true : false, + isPageFilter: activeFilter === 'page' ? true : false, + cursor: null, + }; + + // sort는 group 타입일 때만 추가 + if (activeTab === 'group' && sortType) { + requestParams.sort = sortType; + } + + console.log('API 호출 파라미터:', requestParams); + + const response = await getMemoryPosts(requestParams); + + if (response.isSuccess) { + const convertedRecords = response.data.postList.map(convertPostToRecord); + + if (activeTab === 'my') { + setMyRecords(convertedRecords); + } else { + setGroupRecords(convertedRecords); + } + + setHasRecords(convertedRecords.length > 0); + + console.log('API 응답 성공:', response.data); + } else { + setError(response.message); + console.error('API 응답 실패:', response.message); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : '기록을 불러오는 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('API 호출 오류:', error); + } + }, [roomId, activeTab, selectedSort, selectedPageRange, activeFilter]); + + // 컴포넌트 마운트 시 및 필터/탭 변경 시 데이터 로드 + useEffect(() => { + loadMemoryPosts(); + }, [loadMemoryPosts]); // location.state에서 새로 추가된 기록 확인 - React.useEffect(() => { + useEffect(() => { if (location.state?.newRecord) { const { isUploading, ...recordData } = location.state.newRecord as Record & { isUploading?: boolean; }; if (isUploading) { - // 업로드 프로그레스 시작 setShowUploadProgress(true); - const finalRecord: Record = recordData; - - // 내 기록에 추가 setMyRecords(prev => addRecordIfNotExists(prev, finalRecord)); - // 그룹 기록에도 추가 setGroupRecords(prev => addRecordIfNotExists(prev, finalRecord)); } - // 내 기록 탭으로 이동 setActiveTab('my'); - - // state 즉시 초기화 (중복 추가 방지) navigate(location.pathname, { replace: true, state: null }); } - }, [location.pathname]); + }, [location.state?.newRecord, location.pathname, navigate]); // 업로드 완료 처리 const handleUploadComplete = useCallback(() => { @@ -149,57 +205,24 @@ const Memory = () => { // 정렬된 기록 목록 const sortedRecords = useMemo(() => { - const recordsToSort = [...currentRecords]; - - switch (selectedSort) { - case 'latest': - return recordsToSort.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - case 'popular': - return recordsToSort.sort((a, b) => b.likeCount - a.likeCount); - case 'comments': - return recordsToSort.sort((a, b) => b.commentCount - a.commentCount); - default: - return recordsToSort; - } - }, [currentRecords, selectedSort]); + return currentRecords; + }, [currentRecords]); // 필터링된 기록 목록 const filteredRecords = useMemo(() => { - if (activeTab === 'my') { - // 내 기록에서는 필터링 없이 모든 기록 표시 - return sortedRecords; - } - - if (!activeFilter) return sortedRecords; - - switch (activeFilter) { - case 'page': - if (selectedPageRange) { - // 페이지 범위가 선택된 경우, 해당 범위 내의 기록만 필터링 - return sortedRecords.filter(record => { - if (record.recordType !== 'page' || !record.pageRange) return false; - const recordPage = parseInt(record.pageRange); - return recordPage >= selectedPageRange.start && recordPage <= selectedPageRange.end; - }); - } else { - // 페이지별 보기: 총평이 아닌 기록만 표시 - return sortedRecords.filter(record => record.recordType === 'page'); - } - case 'overall': - // 총평 보기: 총평 기록만 표시 - return sortedRecords.filter(record => record.recordType === 'overall'); - default: - return sortedRecords; - } - }, [activeTab, activeFilter, selectedPageRange, sortedRecords]); + return sortedRecords; + }, [sortedRecords]); const handleBackClick = useCallback(() => { - navigate('/group'); - }, [navigate]); + if (roomId) { + navigate(`/rooms/${roomId}`); + } else { + navigate('/group'); + } + }, [navigate, roomId]); const handleTabChange = useCallback((tab: RecordType) => { setActiveTab(tab); - // 탭 변경 시 필터 초기화 setActiveFilter(null); setSelectedPageRange(null); }, []); @@ -207,7 +230,6 @@ const Memory = () => { const handleFilterChange = useCallback( (filter: FilterType) => { if (activeFilter === filter) { - // 같은 필터를 다시 클릭하면 해제 setActiveFilter(null); setSelectedPageRange(null); } else { @@ -228,13 +250,29 @@ const Memory = () => { const handlePageRangeSet = useCallback((range: { start: number; end: number }) => { setSelectedPageRange(range); - setActiveFilter('page'); // 페이지 범위 설정 시 페이지별 보기로 자동 변경 + setActiveFilter('page'); }, []); const handleToggleRecords = useCallback(() => { setHasRecords(!hasRecords); }, [hasRecords]); + if (error) { + return ( + + + + +
+ 오류가 발생했습니다: {error} + +
+
+ ); + } + return ( diff --git a/src/pages/pollwrite/PollWrite.tsx b/src/pages/pollwrite/PollWrite.tsx index 90e73eae..44b67348 100644 --- a/src/pages/pollwrite/PollWrite.tsx +++ b/src/pages/pollwrite/PollWrite.tsx @@ -1,14 +1,18 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import PollCreationSection from '../../components/pollwrite/PollCreationSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './PollWrite.styled'; import type { Record } from '../memory/Memory'; +import { createVote } from '../../api/record/createVote'; +import type { CreateVoteRequest } from '../../types/record'; const PollWrite = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + const [pageRange, setPageRange] = useState(''); const [pollContent, setPollContent] = useState(''); const [pollOptions, setPollOptions] = useState(['', '']); @@ -27,61 +31,102 @@ const PollWrite = () => { }; const handleCompleteClick = async () => { - if (isSubmitting) return; // 중복 실행 방지 + if (isSubmitting || !roomId) return; // 중복 실행 방지 및 roomId 체크 setIsSubmitting(true); try { - // 페이지 범위 결정: 입력값이 없으면 마지막 기록 페이지 사용 - const finalPageRange = isOverallEnabled - ? undefined + // 페이지 범위 결정: 총평이 아닌 경우 페이지 번호 필요 + const finalPage = isOverallEnabled + ? 0 // 총평인 경우 페이지는 0 : pageRange.trim() !== '' - ? pageRange - : lastRecordedPage.toString(); + ? parseInt(pageRange.trim()) + : lastRecordedPage; // 입력값이 없으면 마지막 기록 페이지 사용 + + // 투표 옵션 필터링 (빈 옵션 제거) + const validOptions = pollOptions.filter(option => option.trim() !== ''); + + if (validOptions.length < 2) { + alert('투표 옵션은 최소 2개 이상이어야 합니다.'); + setIsSubmitting(false); + return; + } + + // API 요청 데이터 생성 + const voteData: CreateVoteRequest = { + page: finalPage, + isOverview: isOverallEnabled, + content: pollContent.trim(), + voteItemList: validOptions.map(option => ({ itemName: option.trim() })), + }; + + console.log('투표 생성 API 호출:', voteData); + console.log('roomId:', roomId); + + // API 호출 + const response = await createVote(parseInt(roomId), voteData); + + if (response.isSuccess) { + console.log('투표 생성 성공:', response.data); - // 투표 옵션 생성 - const pollOptionsData = pollOptions - .filter(option => option.trim() !== '') - .map((option, index) => ({ + // 투표 옵션 생성 (Memory 페이지 표시용) + const pollOptionsData = validOptions.map((option, index) => ({ id: `${index + 1}.`, text: option.trim(), percentage: index === 0 ? 90 : 10, // 첫 번째 옵션을 90%로 설정 (데모용) isHighest: index === 0, // 첫 번째 옵션이 최고값 })); - // 새 투표 기록 객체 생성 (업로드 중 상태로) - const newPollRecord: Record & { isUploading?: boolean } = { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // 고유한 ID - user: 'user.01', // TODO: 실제 사용자 이름으로 변경 - userPoints: 132, // TODO: 실제 사용자 포인트로 변경 - content: pollContent, - likeCount: 0, - commentCount: 0, - timeAgo: '12시간 전', - createdAt: new Date(), - type: 'poll', - recordType: isOverallEnabled ? 'overall' : 'page', - pageRange: finalPageRange, // 최종 페이지 범위 저장 - pollOptions: pollOptionsData, - isUploading: true, // 업로드 중 표시 - }; - - console.log('투표 생성 완료', newPollRecord); - console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPageRange}p`); - console.log('투표 내용:', pollContent); - console.log('투표 옵션:', pollOptionsData); - console.log('총평 설정:', isOverallEnabled); + // 임시로 Memory 페이지용 투표 객체 생성 (기존 인터페이스 호환성을 위해) + const newPollRecord: Record & { isUploading?: boolean } = { + id: response.data.voteId.toString(), + user: 'user.01', // TODO: 실제 사용자 정보로 변경 + userPoints: 132, // TODO: 실제 사용자 포인트로 변경 + content: pollContent, + likeCount: 0, + commentCount: 0, + timeAgo: '방금 전', + createdAt: new Date(), + type: 'poll', + recordType: isOverallEnabled ? 'overall' : 'page', + pageRange: isOverallEnabled ? undefined : finalPage.toString(), + pollOptions: pollOptionsData, + isUploading: false, // API 호출이 완료되었으므로 false + }; - // TODO: API 호출하여 서버에 투표 저장 - // await api.createPoll(newPollRecord); - - // 바로 기록장으로 이동 (업로드 중인 투표와 함께) - navigate('/memory', { - state: { newRecord: newPollRecord }, - replace: true, - }); + // 성공 시 기록장으로 이동 + navigate(`/rooms/${roomId}/memory`, { + state: { newRecord: newPollRecord }, + replace: true, + }); + } else { + // API 에러 응답 처리 + console.error('투표 생성 실패:', response.message); + alert(`투표 생성에 실패했습니다: ${response.message}`); + setIsSubmitting(false); + } } catch (error) { console.error('투표 저장 실패:', error); + + // 에러 타입에 따른 메시지 처리 + let errorMessage = '투표 저장 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + status: number; + data?: { message?: string }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.status) { + errorMessage = `서버 오류 (${axiosError.response.status})`; + } + } + + alert(errorMessage); setIsSubmitting(false); } }; diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 9cf4885b..3c36d722 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import BookSearchBottomSheet from '../../components/common/BookSearchBottomSheet/BookSearchBottomSheet'; import BookSelectionSection from '../../components/creategroup/BookSelectionSection'; @@ -34,7 +34,7 @@ const makeIsbnCandidates = (raw: string) => { }; interface Book { - id: number; + id?: number; title: string; author: string; cover: string; @@ -43,7 +43,21 @@ interface Book { const CreatePost = () => { const navigate = useNavigate(); - const [selectedBook, setSelectedBook] = useState(null); + const location = useLocation(); + + function convertBookInfoToBook(bookInfo: Book): Book | null { + if (!bookInfo) return null; + return { + title: bookInfo.title, + author: bookInfo.author, + cover: bookInfo.cover, + isbn: bookInfo.isbn, + }; + } + + const [selectedBook, setSelectedBook] = useState( + convertBookInfoToBook(location.state?.selectedBook), + ); const [postContent, setPostContent] = useState(''); const [selectedPhotos, setSelectedPhotos] = useState([]); const [isPrivate, setIsPrivate] = useState(false); diff --git a/src/pages/recordwrite/RecordWrite.tsx b/src/pages/recordwrite/RecordWrite.tsx index 7f8c7490..d147f694 100644 --- a/src/pages/recordwrite/RecordWrite.tsx +++ b/src/pages/recordwrite/RecordWrite.tsx @@ -1,14 +1,18 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import RecordContentSection from '../../components/recordwrite/RecordContentSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './RecordWrite.styled'; import type { Record } from '../memory/Memory'; +import { createRecord } from '../../api/record/createRecord'; +import type { CreateRecordRequest } from '../../types/record'; const RecordWrite = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + const [pageRange, setPageRange] = useState(''); const [content, setContent] = useState(''); const [isOverallEnabled, setIsOverallEnabled] = useState(false); @@ -26,55 +30,90 @@ const RecordWrite = () => { }; const handleCompleteClick = async () => { - if (isSubmitting) return; // 중복 실행 방지 + if (isSubmitting || !roomId) return; // 중복 실행 방지 및 roomId 체크 setIsSubmitting(true); try { - // 페이지 범위 결정: 입력값이 없으면 마지막 기록 페이지 사용 - const finalPageRange = isOverallEnabled - ? undefined + // 페이지 범위 결정: 총평이 아닌 경우 페이지 번호 필요 + const finalPage = isOverallEnabled + ? 0 // 총평인 경우 페이지는 0 : pageRange.trim() !== '' - ? pageRange - : lastRecordedPage.toString(); - - // 새 기록 객체 생성 (업로드 중 상태로) - const newRecord: Record & { isUploading?: boolean } = { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // 고유한 ID - user: 'user.01', // TODO: 실제 사용자 이름으로 변경 - userPoints: 132, // TODO: 실제 사용자 포인트로 변경 - content: content, - likeCount: 0, - commentCount: 0, - timeAgo: '12시간 전', - createdAt: new Date(), - type: 'text', - recordType: isOverallEnabled ? 'overall' : 'page', - pageRange: finalPageRange, // 최종 페이지 범위 저장 - isUploading: true, // 업로드 중 표시 + ? parseInt(pageRange.trim()) + : lastRecordedPage; // 입력값이 없으면 마지막 기록 페이지 사용 + + // API 요청 데이터 생성 + const recordData: CreateRecordRequest = { + page: finalPage, + isOverview: isOverallEnabled, + content: content.trim(), }; - console.log('기록 작성 완료', newRecord); - console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPageRange}p`); - console.log('내용:', content); - console.log('총평 설정:', isOverallEnabled); + console.log('기록 생성 API 호출:', recordData); + console.log('roomId:', roomId); + + // API 호출 + const response = await createRecord(parseInt(roomId), recordData); + + if (response.isSuccess) { + console.log('기록 생성 성공:', response.data); - // TODO: API 호출하여 서버에 기록 저장 - // await api.createRecord(newRecord); + // 임시로 Memory 페이지용 기록 객체 생성 (기존 인터페이스 호환성을 위해) + const newRecord: Record & { isUploading?: boolean } = { + id: response.data.recordId.toString(), + user: 'user.01', // TODO: 실제 사용자 정보로 변경 + userPoints: 132, // TODO: 실제 사용자 포인트로 변경 + content: content, + likeCount: 0, + commentCount: 0, + timeAgo: '방금 전', + createdAt: new Date(), + type: 'text', + recordType: isOverallEnabled ? 'overall' : 'page', + pageRange: isOverallEnabled ? undefined : finalPage.toString(), + isUploading: false, // API 호출이 완료되었으므로 false + }; - // 바로 기록장으로 이동 (업로드 중인 기록과 함께) - navigate('/memory', { - state: { newRecord }, - replace: true, - }); + // 성공 시 기록장으로 이동 + navigate(`/rooms/${roomId}/memory`, { + state: { newRecord }, + replace: true, + }); + } else { + // API 에러 응답 처리 + console.error('기록 생성 실패:', response.message); + alert(`기록 생성에 실패했습니다: ${response.message}`); + setIsSubmitting(false); + } } catch (error) { console.error('기록 저장 실패:', error); + + // 에러 타입에 따른 메시지 처리 + let errorMessage = '기록 저장 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + status: number; + data?: { message?: string }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.status) { + errorMessage = `서버 오류 (${axiosError.response.status})`; + } + } + + alert(errorMessage); setIsSubmitting(false); } }; - // 총평이 켜져있으면 내용만 필요, 아니면 내용은 필수 (페이지는 기본값 사용 가능) - const isFormValid = content.trim() !== ''; + // 폼 유효성 검사: 내용은 필수, 총평이 아닌 경우 페이지 번호도 확인 + const isFormValid = + content.trim() !== '' && (isOverallEnabled || pageRange.trim() !== '' || lastRecordedPage > 0); return ( <> diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index b5f4007a..fb5a28ec 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -6,8 +6,12 @@ import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import SearchBar from '@/components/search/SearchBar'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; +import { getSearchBooks, convertToSearchedBooks } from '@/api/books/getSearchBooks'; +import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; +import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; export interface SearchedBook { id: number; @@ -15,80 +19,247 @@ export interface SearchedBook { author: string; publisher: string; coverUrl: string; + isbn: string; } -const dummySearchedBook: SearchedBook[] = [ - { - id: 1, - title: '채식주의자', - author: '한강', - publisher: '창비', - coverUrl: 'https://image.yes24.com/goods/17122707/XL', - }, - { - id: 2, - title: '채소 마스터 클래스', - author: '백지혜', - publisher: '세미콜론', - coverUrl: 'https://image.yes24.com/goods/109378551/XL', - }, - { - id: 3, - title: '채소 식탁', - author: '김경민', - publisher: '래디시', - coverUrl: 'https://image.yes24.com/goods/117194041/XL', - }, -]; const Search = () => { + const [searchParams, setSearchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); - const [isSearched, setIsSearched] = useState(false); + const [isFinalized, setIsFinalized] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); - const [recentSearches, setRecentSearches] = useState([ - '딸기12', - '당근', - '수박245', - '참', - '메론1', - ]); + // 무한스크롤 관련 상태 + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(1); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const [recentSearches, setRecentSearches] = useState([]); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); + + // Intersection Observer를 위한 ref + const observerRef = useRef(null); + const lastBookElementRef = useRef(null); + + const fetchRecentSearches = async () => { + try { + const response = await getRecentSearch('BOOK'); + + if (response.isSuccess) { + setRecentSearches(response.data.recentSearchList); + } else { + console.error('최근 검색어 조회 실패:', response.message); + setRecentSearches([]); + } + } catch (error) { + console.error('최근 검색어 조회 오류:', error); + setRecentSearches([]); + } + }; + + useEffect(() => { + fetchRecentSearches(); + }, []); + + // 무한스크롤을 위한 Intersection Observer 설정 + const lastBookElementCallback = useCallback( + (node: HTMLDivElement | null) => { + if (isLoadingMore || !hasMore) return; + + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + loadMore(); + } + }); + if (node) observerRef.current.observe(node); + lastBookElementRef.current = node; + }, + [isLoadingMore, hasMore], + ); + + // 추가 데이터 로드 함수 + const loadMore = async () => { + if (!searchTerm.trim() || isLoadingMore || !hasMore) return; + + try { + setIsLoadingMore(true); + const nextPage = page + 1; + + const response = await getSearchBooks(searchTerm, nextPage, isFinalized); + + if (response.isSuccess) { + const newResults = convertToSearchedBooks(response.data.searchResult); + + if (newResults.length > 0) { + setSearchResults(prev => [...prev, ...newResults]); + setPage(nextPage); + // 더 이상 데이터가 없으면 hasMore를 false로 설정 + setHasMore(newResults.length === 10); // size가 10이므로 + } else { + setHasMore(false); + } + } else { + console.error('추가 데이터 로드 실패:', response.message); + setHasMore(false); + } + } catch (error) { + console.error('추가 데이터 로드 중 오류 발생:', error); + setHasMore(false); + } finally { + setIsLoadingMore(false); + } + }; const handleChange = (value: string) => { setSearchTerm(value); - setIsSearched(false); + setIsFinalized(false); setIsSearching(value.trim() !== ''); + setHasMore(true); // 새로운 검색 시 hasMore 초기화 + setPage(1); // 페이지 초기화 + + if (value.trim()) { + setSearchParams({ q: value.trim() }, { replace: true }); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + + const timeoutId = setTimeout(() => { + handleSearch(value.trim(), false); + }, 300); + + setSearchTimeoutId(timeoutId); + } else { + setSearchParams({}, { replace: true }); + setSearchResults([]); + setIsFinalized(false); + setHasMore(true); + setPage(1); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + } }; - const handleSearch = (term: string) => { + const handleSearch = useCallback(async (term: string, isManualSearch: boolean = false) => { if (!term.trim()) return; + setIsSearching(true); - setIsSearched(true); - setRecentSearches(prev => { - const filtered = prev.filter(t => t !== term); - return [term, ...filtered].slice(0, 5); - }); + + if (isManualSearch) { + setIsFinalized(false); + } + + setIsLoading(true); + setPage(1); // 검색 시 페이지 초기화 + setHasMore(true); // 검색 시 hasMore 초기화 + + try { + const response = await getSearchBooks(term, 1, isManualSearch); + + if (response.isSuccess) { + const convertedResults = convertToSearchedBooks(response.data.searchResult); + setSearchResults(convertedResults); + // 더 이상 데이터가 없으면 hasMore를 false로 설정 + setHasMore(convertedResults.length === 10); // size가 10이므로 + } else { + console.log('검색 실패:', response.message); + setSearchResults([]); + setHasMore(false); + } + + if (isManualSearch) { + setIsFinalized(true); + } + } catch (error) { + console.error('검색 중 오류 발생:', error); + setSearchResults([]); + setHasMore(false); + if (isManualSearch) { + setIsFinalized(true); + } + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + const query = searchParams.get('q') || ''; + if (query && !isInitialized) { + setSearchTerm(query); + setIsSearching(true); + handleSearch(query, true); + setIsInitialized(true); + } + }, [searchParams, handleSearch, isInitialized]); + + useEffect(() => { + if (searchTerm.trim() === '') { + setIsSearching(false); + setIsFinalized(false); + } + }, [searchTerm]); + + const handleDelete = async (recentSearchId: number) => { + try { + const userId = 1; // 임시 userId + + const response = await deleteRecentSearch(recentSearchId, userId); + + if (response.isSuccess) { + setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); + } else { + console.error('최근 검색어 삭제 실패:', response.message); + } + } catch (error) { + console.error('최근 검색어 삭제 오류:', error); + } }; - const handleDelete = (recentSearch: string) => { - setRecentSearches(prev => prev.filter(t => t !== recentSearch)); + const handleDeleteWrapper = (searchTerm: string) => { + const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); + if (recentSearchItem) { + handleDelete(recentSearchItem.recentSearchId); + } }; const handleRecentSearchClick = (recentSearch: string) => { setSearchTerm(recentSearch); - setIsSearched(true); - setIsSearching(true); + handleSearch(recentSearch, true); }; const handleBackButton = () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + setSearchTerm(''); + setSearchResults([]); + setIsSearching(false); + setIsFinalized(false); + setIsInitialized(false); + setHasMore(true); + setPage(1); + setSearchParams({}, { replace: true }); }; useEffect(() => { - if (searchTerm.trim() === '') { - setIsSearching(false); - setIsSearched(false); - } - }, [searchTerm]); + return () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [searchTimeoutId]); return ( @@ -106,32 +277,30 @@ const Search = () => { placeholder="책 제목, 작가명을 검색해보세요." value={searchTerm} onChange={handleChange} - onSearch={() => handleSearch(searchTerm.trim())} - isSearched={isSearched} + onSearch={() => handleSearch(searchTerm.trim(), true)} + isSearched={isFinalized} /> {isSearching ? ( <> - ( - {isSearched ? ( - + {isLoading && searchResults.length === 0 ? ( + 검색 중... ) : ( + type={isFinalized ? 'searched' : 'searching'} + searchedBookList={searchResults} + hasMore={hasMore} + isLoading={isLoadingMore} + lastBookElementCallback={lastBookElementCallback} + /> )} - ) ) : ( <> item.searchTerm)} + handleDelete={handleDeleteWrapper} handleRecentSearchClick={handleRecentSearchClick} /> @@ -151,7 +320,8 @@ const Wrapper = styled.div` flex-direction: column; min-width: 320px; max-width: 767px; - height: 100vh; + height: 100%; + min-height: 100vh; margin: 0 auto; background: ${colors.black.main}; `; @@ -186,3 +356,12 @@ const SearchBarContainer = styled.div` const Content = styled.div` margin-top: 132px; `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.white}; + font-size: ${typography.fontSize.base}; +`; diff --git a/src/pages/searchBook/SearchBook.styled.ts b/src/pages/searchBook/SearchBook.styled.ts index 7ada1f38..303d62da 100644 --- a/src/pages/searchBook/SearchBook.styled.ts +++ b/src/pages/searchBook/SearchBook.styled.ts @@ -59,7 +59,7 @@ export const BannerSection = styled.section` flex-direction: column; width: 100%; padding: 20px; - margin-top: 66px; + margin-top: 24%; gap: 32px; color: ${colors.white}; z-index: 10; @@ -126,6 +126,7 @@ export const RecruitingGroupButton = styled.button` justify-content: center; align-items: center; padding: 10px 12px; + cursor: pointer; `; export const RightArea = styled.div` @@ -150,17 +151,19 @@ export const WritePostButton = styled.button` gap: 8px; min-width: 200px; border: none; + cursor: pointer; `; export const SaveButton = styled.button` width: 48px; height: 48px; background: transparent; - border: 1px solid ${colors.grey[200]}; + border: none; border-radius: 12px; display: flex; align-items: center; justify-content: center; + cursor: pointer; `; export const FeedSection = styled.section` diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 9d3b0085..287dfcf6 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -21,28 +21,76 @@ import { EmptyTitle, EmptySubText, } from './SearchBook.styled'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; -import moreIcon from '../../assets/common/more.svg'; import { IconButton } from '@/components/common/IconButton'; -import { mockSearchBook } from '@/mocks/searchBook.mock'; import saveIcon from '../../assets/common/SaveIcon.svg'; +import filledSaveIcon from '../../assets/common/filledSaveIcon.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import plusIcon from '../../assets/common/plus.svg'; +import { useState, useEffect } from 'react'; +import { IntroModal } from '@/components/search/IntroModal'; +import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; +import { getRecruitingRooms, type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; +import { postSaveBook } from '@/api/books/postSaveBook'; import { Filter } from '@/components/common/Filter'; -import { useState } from 'react'; import FeedPost from '@/components/feed/FeedPost'; -import { IntroModal } from '@/components/search/IntroModal'; +import { mockSearchBook } from '@/mocks/searchBook.mock'; const FILTER = ['최신순', '인기순']; const SearchBook = () => { - const { title, author, introduction, coverUrl, recruitGroups, posts } = mockSearchBook; + const { isbn } = useParams<{ isbn: string }>(); const [selectedFilter, setSelectedFilter] = useState('인기순'); const [showIntroModal, setShowIntroModal] = useState(false); + const [bookDetail, setBookDetail] = useState(null); + const [recruitingRoomsData, setRecruitingRoomsData] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const navigate = useNavigate(); + useEffect(() => { + const fetchBookDetail = async () => { + if (!isbn) { + setError('ISBN이 필요합니다.'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + + const [bookResponse, recruitingResponse] = await Promise.all([ + getBookDetail(isbn), + getRecruitingRooms(isbn), + ]); + + if (bookResponse.isSuccess) { + setBookDetail(bookResponse.data); + setIsSaved(bookResponse.data.isSaved); + } else { + setError(bookResponse.message); + } + + if (recruitingResponse.isSuccess) { + setRecruitingRoomsData(recruitingResponse.data); + } else { + console.error('모집중인 모임방 조회 실패:', recruitingResponse.message); + } + } catch (error) { + console.error('데이터 조회 오류:', error); + setError('정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchBookDetail(); + }, [isbn]); + const handleBackButton = () => { navigate(-1); }; @@ -51,51 +99,104 @@ const SearchBook = () => { const handleCloseIntroModal = () => setShowIntroModal(false); - const handleMoreButton = () => {}; - const handleRecruitingGroupButton = () => { - navigate('./group'); + if (bookDetail) { + navigate('/search/book/group', { + state: { + recruitingRooms: recruitingRoomsData || { + recruitingRoomList: [], + totalRoomCount: 0, + nextCursor: '', + isLast: true, + }, + bookInfo: { + isbn: bookDetail.isbn, + title: bookDetail.title, + author: bookDetail.authorName, + imageUrl: bookDetail.imageUrl, + }, + }, + }); + } }; - const handleWritePostButton = () => {}; + const handleWritePostButton = () => { + if (bookDetail) { + const selectedBook = { + title: bookDetail.title, + author: bookDetail.authorName, + cover: bookDetail.imageUrl, + isbn: bookDetail.isbn, + }; + navigate('/post/create', { state: { selectedBook } }); + } else { + navigate('/post/create'); + } + }; - const handleSaveButton = () => {}; + const handleSaveButton = async () => { + if (!isbn || isSaving) return; + + try { + setIsSaving(true); + const response = await postSaveBook(isbn, !isSaved); + + if (response.isSuccess) { + setIsSaved(response.data.isSaved); + } else { + console.error('북마크 실패:', response.message); + } + } catch (error) { + console.error('북마크 중 오류 발생:', error); + } finally { + setIsSaving(false); + } + }; - const hasFeeds = mockSearchBook.posts.length > 0; + if (isLoading || error || !bookDetail) { + return ( + +
+ +
+
+ {isLoading ? '로딩 중...' : error || '책 정보를 찾을 수 없습니다.'} +
+
+ ); + } return ( - +
-
- {title} - {author} + {bookDetail.title} + {bookDetail.authorName} 소개 - {introduction} + {bookDetail.description} - 모집중인 모임방 {recruitGroups.length}개{' '} + 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} 오른쪽 화살표 아이콘 피드에 글쓰기 더하기 아이콘 - - 저장 버튼 + + 저장 버튼 - 피드 글 둘러보기 @@ -106,10 +207,10 @@ const SearchBook = () => { /> - {hasFeeds ? ( + {mockSearchBook.posts.length > 0 ? ( <> - {posts.map(post => ( - + {mockSearchBook.posts.map((post, index) => ( + ))} ) : ( @@ -118,10 +219,9 @@ const SearchBook = () => { 첫 번째 피드를 작성해보세요!
)} - - + {' '} {showIntroModal && ( - + )} ); diff --git a/src/pages/searchBook/SearchBookGroup.tsx b/src/pages/searchBook/SearchBookGroup.tsx index 02fe4941..c3aff3d3 100644 --- a/src/pages/searchBook/SearchBookGroup.tsx +++ b/src/pages/searchBook/SearchBookGroup.tsx @@ -2,19 +2,41 @@ import TitleHeader from '@/components/common/TitleHeader'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { GroupCard } from '@/components/group/GroupCard'; -import { mockSearchBookGroup } from '@/mocks/searchBook.mock'; +import { type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; + +interface LocationState { + recruitingRooms: RecruitingRoomsData; + bookInfo: { + isbn: string; + title: string; + author: string; + imageUrl: string; + }; +} const SearchBookGroup = () => { const navigate = useNavigate(); + const location = useLocation(); + const { recruitingRooms, bookInfo } = (location.state as LocationState) || {}; const handleBackButton = () => { navigate(-1); }; - const handleMakeGroup = () => {}; + const handleMakeGroup = () => { + const selectedBook = { + title: bookInfo.title, + author: bookInfo.author, + cover: bookInfo.imageUrl, + isbn: bookInfo.isbn, + }; + navigate('/group/create', { state: { selectedBook } }); + }; - const hasGroups = mockSearchBookGroup.length > 0; + const groupList = recruitingRooms?.recruitingRoomList || []; + const totalCount = recruitingRooms?.totalRoomCount || 0; + const hasGroups = groupList.length > 0; return ( @@ -23,11 +45,23 @@ const SearchBookGroup = () => { leftIcon={뒤로 가기} onLeftClick={handleBackButton} /> - 전체 {mockSearchBookGroup.length} + 전체 {totalCount} {hasGroups ? ( - {mockSearchBookGroup.map(group => ( - + {groupList.map((room, index) => ( + ))} ) : ( diff --git a/src/pages/signup/SignupDone.tsx b/src/pages/signup/SignupDone.tsx index f5472185..87ef344f 100644 --- a/src/pages/signup/SignupDone.tsx +++ b/src/pages/signup/SignupDone.tsx @@ -12,19 +12,13 @@ const SignupDone = () => { const { nickName, aliasName } = location.state || {}; const handleBackClick = () => { - navigate('/signup/genre'); + navigate('/signup/guide'); }; const handleNextClick = () => { navigate('/feed'); }; - // state가 없으면 이전 페이지로 이동 - if (!nickName || !aliasName) { - navigate('/signup/nickname'); - return null; - } - return ( { const result = await postSignup({ aliasName: selectedAlias.subTitle, nickname: nickname, + isTokenRequired: false, }); if (result.success) { console.log('🎉 회원가입 성공! 사용자 ID:', result.data.userId); - navigate('/signupdone', { + navigate('/signup/guide', { state: { aliasName: selectedAlias.subTitle, nickname: nickname, diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index 86c91761..632cfc8a 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -1,5 +1,5 @@ -import { useState, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, useRef, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import EmptyState from '../../components/today-words/EmptyState'; import MessageList from '../../components/today-words/MessageList/MessageList'; @@ -9,12 +9,17 @@ import leftarrow from '../../assets/common/leftArrow.svg'; import { Container, ContentArea } from './TodayWords.styled'; import type { Message } from '../../types/today'; import { dummyMessages } from '../../constants/today-constants'; +import { createDailyGreeting } from '../../api/rooms/createDailyGreeting'; +import { usePopupActions } from '../../hooks/usePopupActions'; const TodayWords = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); const messageListRef = useRef(null); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const { openSnackbar } = usePopupActions(); // 개발용: 빈 상태와 글 있는 상태 토글 const [showMessages, setShowMessages] = useState(false); @@ -23,45 +28,123 @@ const TodayWords = () => { navigate(-1); }; - const handleSendMessage = () => { - if (inputValue.trim() === '') return; + const handleSendMessage = useCallback(async () => { + if (inputValue.trim() === '' || isSubmitting) return; - // 빈 상태에서 메시지를 보낼 때 실제 messages 상태를 업데이트 - if (!showMessages) { - // 새 메시지 생성 - const now = new Date(); - const newMessage: Message = { - id: Date.now().toString(), - user: 'user.01', - content: inputValue.trim(), - timestamp: now - .toLocaleDateString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }) - .replace(/\. /g, '.') - .replace(/\.$/, ''), - timeAgo: '방금 전', - createdAt: now, - }; - - // 실제 messages 상태에 추가 - setMessages(prevMessages => [...prevMessages, newMessage]); - } else { - // MessageList의 addMessage 함수 호출 (더미 데이터 상태일 때) - if (messageListRef.current) { - messageListRef.current.addMessage(inputValue.trim()); + // roomId가 없으면 에러 처리 + if (!roomId) { + openSnackbar({ + message: '방 정보를 찾을 수 없습니다.', + variant: 'top', + onClose: () => {}, + }); + return; + } + + setIsSubmitting(true); + + try { + // API 호출 - 오늘의 한마디 작성 + const response = await createDailyGreeting(parseInt(roomId), inputValue.trim()); + + if (response.isSuccess) { + // 성공 시 새 메시지 생성 + const now = new Date(); + const newMessage: Message = { + id: response.data.attendanceCheckId.toString(), + user: 'user.01', // TODO: 실제 사용자 정보로 변경 + content: inputValue.trim(), + timestamp: now + .toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .replace(/\. /g, '.') + .replace(/\.$/, ''), + timeAgo: '방금 전', + createdAt: now, + }; + + // 실제 messages 상태에 추가 + setMessages(prevMessages => [...prevMessages, newMessage]); + + // 입력 필드 초기화 + setInputValue(''); + + // 성공 메시지 표시 + openSnackbar({ + message: '오늘의 한마디가 작성되었습니다.', + variant: 'top', + onClose: () => {}, + }); + + // 자동으로 스크롤을 아래로 이동 + setTimeout(() => { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }, 100); + } else { + // API 에러 응답 처리 + openSnackbar({ + message: response.message || '오늘의 한마디 작성에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); } + } catch (error) { + console.error('오늘의 한마디 작성 오류:', error); + + // 에러 타입에 따른 메시지 처리 + let errorMessage = '오늘의 한마디 작성 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + data?: { + message?: string; + code?: number; + }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.data?.code === 400) { + errorMessage = '오늘의 한마디 작성 가능 횟수를 초과했습니다.'; + } else if (axiosError.response?.data?.code === 403) { + errorMessage = '방 접근 권한이 없습니다.'; + } else if (axiosError.response?.data?.code === 404) { + errorMessage = '존재하지 않는 방입니다.'; + } + } + + openSnackbar({ + message: errorMessage, + variant: 'top', + onClose: () => {}, + }); + } finally { + setIsSubmitting(false); } + }, [inputValue, roomId, isSubmitting, openSnackbar]); + // 더미 모드에서 메시지 전송 처리 (개발용) + const handleDummySendMessage = useCallback(() => { + if (inputValue.trim() === '') return; + + if (messageListRef.current) { + messageListRef.current.addMessage(inputValue.trim()); + } setInputValue(''); // 자동으로 스크롤을 아래로 이동 setTimeout(() => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }, 100); - }; + }, [inputValue]); + + // 최종 메시지 전송 핸들러 + const finalHandleSendMessage = showMessages ? handleDummySendMessage : handleSendMessage; // MessageList에서 메시지가 삭제되었을 때 호출될 콜백 const handleMessageDelete = (messageId: string) => { @@ -97,8 +180,9 @@ const TodayWords = () => { {/* 개발용 토글 버튼 */} diff --git a/src/types/memory.ts b/src/types/memory.ts new file mode 100644 index 00000000..ac5ebbf9 --- /dev/null +++ b/src/types/memory.ts @@ -0,0 +1,56 @@ +// 투표 아이템 타입 +export interface VoteItem { + voteItemId: number; + itemName: string; + percentage: number; + isVoted: boolean; +} + +// 기록/투표 포스트 타입 +export interface Post { + postId: number; + postDate: string; + postType: 'RECORD' | 'VOTE'; + page: number; + userId: number; + nickName: string; + profileImageUrl: string; + content: string; + likeCount: number; + commentCount: number; + isOverview: boolean; + isLiked: boolean; + isWriter: boolean; + isLocked: boolean; // 블러 처리 여부 + voteItems: VoteItem[]; +} + +// 기록장 조회 요청 파라미터 타입 +export interface GetMemoryPostsParams { + roomId: number; + type?: 'group' | 'mine'; // default: group + sort?: 'latest' | 'like' | 'comment'; // default: latest (type이 group인 경우만) + pageStart?: number | null; // 페이지 필터 시작 (default: null) + pageEnd?: number | null; // 페이지 필터 끝 (default: null) + isOverview?: boolean; // 총평 보기 필터 (default: false) + isPageFilter?: boolean; // 페이지 보기 필터 (default: false) + cursor?: string | null; // 페이지네이션 커서 +} + +// 기록장 조회 응답 데이터 타입 +export interface MemoryPostsData { + postList: Post[]; + roomId: number; + isOverviewEnabled: boolean; + isbn: string; + nextCursor: string | null; + isLast: boolean; +} + +// API 응답 타입 +export interface GetMemoryPostsResponse { + isSuccess: boolean; + code: number; + message: string; + data: MemoryPostsData; +} diff --git a/src/types/post.ts b/src/types/post.ts index 44d23e38..f275a192 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -15,6 +15,7 @@ export interface PostData { isSaved?: boolean; isLiked?: boolean; isPublic?: boolean; + isWriter?: boolean; } export interface FeedListProps { @@ -50,6 +51,7 @@ export interface SubReplyDataProps { likeCount: number; replyId: number; isLike: boolean; + isWriter: boolean; } // 댓글(Reply) @@ -65,6 +67,7 @@ export interface ReplyDataProps { likeCount: number; isLike: boolean; isDeleted: boolean; + isWriter: boolean; replyList: SubReplyDataProps[]; } diff --git a/src/types/profile.ts b/src/types/profile.ts index 54d6d023..0a75c757 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -12,4 +12,5 @@ export interface MyProfileData { // 다른 사용자 프로필 정보 타입 (isFollowing 포함) export interface OtherProfileData extends MyProfileData { isFollowing: boolean; + isWriter?: boolean; } diff --git a/src/types/record.ts b/src/types/record.ts new file mode 100644 index 00000000..e5e421fe --- /dev/null +++ b/src/types/record.ts @@ -0,0 +1,39 @@ +// 기록 생성 요청 데이터 타입 +export interface CreateRecordRequest { + page: number; // 페이지 번호 + isOverview: boolean; // 총평 여부 + content: string; // 기록 내용 +} + +// 기록 생성 응답 데이터 타입 +export interface CreateRecordData { + recordId: number; // 생성된 기록 ID + roomId: number; // 방 ID +} + +// 투표 아이템 타입 +export interface VoteItem { + itemName: string; // 투표 옵션 이름 +} + +// 투표 생성 요청 데이터 타입 +export interface CreateVoteRequest { + page: number; // 페이지 번호 + isOverview: boolean; // 총평 여부 + content: string; // 투표 내용 + voteItemList: VoteItem[]; // 투표 옵션 리스트 +} + +// 투표 생성 응답 데이터 타입 +export interface CreateVoteData { + voteId: number; // 생성된 투표 ID + roomId: number; // 방 ID +} + +// 공통 API 응답 타입 +export interface ApiResponse { + isSuccess: boolean; + code: number; + message: string; + data: T; +}