diff --git a/src/api/record/createAiReview.ts b/src/api/record/createAiReview.ts new file mode 100644 index 00000000..72d37c5d --- /dev/null +++ b/src/api/record/createAiReview.ts @@ -0,0 +1,31 @@ +import { apiClient } from '../index'; +import type { CreateAiReviewData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type CreateAiReviewResponse = ApiResponse; + +// AI 독서감상문 생성 API 함수 +export const createAiReview = async (roomId: number) => { + const response = await apiClient.post( + `/rooms/${roomId}/record/ai-review`, + ); + return response.data; +}; + +/* +사용 예시: +try { + const result = await createAiReview(1); + if (result.isSuccess) { + console.log("생성된 독서감상문:", result.data.content); + console.log("잔여 이용 횟수:", result.data.count); + // 성공 처리 로직 + } else { + console.error("AI 독서감상문 생성 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/record/getAiUsage.ts b/src/api/record/getAiUsage.ts new file mode 100644 index 00000000..9360b5d9 --- /dev/null +++ b/src/api/record/getAiUsage.ts @@ -0,0 +1,31 @@ +import { apiClient } from '../index'; +import type { AiUsageData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type GetAiUsageResponse = ApiResponse; + +// AI 이용 횟수 조회 API 함수 +export const getAiUsage = async (roomId: number) => { + const response = await apiClient.get( + `/rooms/${roomId}/users/ai-usage`, + ); + return response.data; +}; + +/* +사용 예시: +try { + const result = await getAiUsage(1); + if (result.isSuccess) { + console.log("AI 독서감상문 작성 가능 횟수:", result.data.recordReviewCount); + console.log("기록 작성 횟수:", result.data.recordCount); + // 성공 처리 로직 + } else { + console.error("AI 이용 횟수 조회 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/record/index.ts b/src/api/record/index.ts index 8a096063..db7c2243 100644 --- a/src/api/record/index.ts +++ b/src/api/record/index.ts @@ -3,4 +3,6 @@ export * from './createVote'; export * from './deleteRecord'; export * from './deleteVote'; export * from './postVote'; -export * from './pinRecordToFeed'; \ No newline at end of file +export * from './pinRecordToFeed'; +export * from './createAiReview'; +export * from './getAiUsage'; \ No newline at end of file diff --git a/src/assets/common/infoIcon_white.svg b/src/assets/common/infoIcon_white.svg new file mode 100644 index 00000000..6e86bad6 --- /dev/null +++ b/src/assets/common/infoIcon_white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/feed/people.svg b/src/assets/feed/people.svg index b207e318..16ffaaa1 100644 --- a/src/assets/feed/people.svg +++ b/src/assets/feed/people.svg @@ -1,5 +1,6 @@ - - - - - + + + + + + \ No newline at end of file diff --git a/src/assets/memory/ai.svg b/src/assets/memory/ai.svg new file mode 100644 index 00000000..ffef4a08 --- /dev/null +++ b/src/assets/memory/ai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 2ecf80d4..e0b1cef7 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -15,9 +15,10 @@ interface BookSearchBottomSheetProps { isOpen: boolean; onClose: () => void; onSelectBook: (book: Book) => void; + showGroupTab?: boolean; } -const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { +const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook, showGroupTab = true }: BookSearchBottomSheetProps) => { const { searchQuery, filteredBooks, @@ -37,7 +38,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott loadMoreSearchResults, loadMoreSavedBooks, loadMoreGroupBooks, - } = useBookSearch(); + } = useBookSearch(showGroupTab); // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { @@ -109,7 +110,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott /> {/* 탭 영역 */} - {showTabs && } + {showTabs && } {/* 책 목록 영역 */} diff --git a/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx index ef5e8660..0d154c8b 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx @@ -5,17 +5,20 @@ export type TabType = 'saved' | 'group'; interface BookSearchTabsProps { activeTab: TabType; onTabChange: (tab: TabType) => void; + showGroupTab?: boolean; } -const BookSearchTabs = ({ activeTab, onTabChange }: BookSearchTabsProps) => { +const BookSearchTabs = ({ activeTab, onTabChange, showGroupTab = true }: BookSearchTabsProps) => { return ( onTabChange('saved')}> 저장한 책 - onTabChange('group')}> - 모임 책 - + {showGroupTab && ( + onTabChange('group')}> + 모임 책 + + )} ); }; diff --git a/src/components/common/BookSearchBottomSheet/useBookSearch.ts b/src/components/common/BookSearchBottomSheet/useBookSearch.ts index d7c0a9e8..453bbd14 100644 --- a/src/components/common/BookSearchBottomSheet/useBookSearch.ts +++ b/src/components/common/BookSearchBottomSheet/useBookSearch.ts @@ -4,7 +4,7 @@ import { getSearchBooks, convertToSearchedBooks, type SearchedBook } from '@/api import type { Book } from './BookList'; import type { TabType } from './BookSearchTabs'; -export const useBookSearch = () => { +export const useBookSearch = (showGroupTab: boolean = true) => { const [searchQuery, setSearchQuery] = useState(''); const [filteredBooks, setFilteredBooks] = useState([]); const [activeTab, setActiveTab] = useState('saved'); @@ -17,7 +17,7 @@ export const useBookSearch = () => { const [currentPage, setCurrentPage] = useState(1); const [hasNextPage, setHasNextPage] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); - + // 저장한 책/모임 책 무한 스크롤 관련 상태 const [savedBooksCursor, setSavedBooksCursor] = useState(null); const [groupBooksCursor, setGroupBooksCursor] = useState(null); @@ -185,7 +185,7 @@ export const useBookSearch = () => { if (isLoadingMoreSavedBooks || !hasSavedBooksNext) { return; } - + await fetchSavedBooks(true); }; @@ -194,7 +194,7 @@ export const useBookSearch = () => { if (isLoadingMoreGroupBooks || !hasGroupBooksNext) { return; } - + await fetchGroupBooks(true); }; @@ -251,7 +251,7 @@ export const useBookSearch = () => { const loadInitialData = () => { if (activeTab === 'saved' && savedBooks.length === 0) { fetchSavedBooks(); - } else if (activeTab === 'group' && groupBooks.length === 0) { + } else if (activeTab === 'group' && groupBooks.length === 0 && showGroupTab) { fetchGroupBooks(); } }; @@ -262,7 +262,7 @@ export const useBookSearch = () => { const hasBooks = isSearchMode ? searchResults.length > 0 : currentTabBooks.length > 0; const showEmptyState = !isLoading && !error && !hasBooks; const showTabs = !isSearchMode; // 검색 모드가 아닐 때는 항상 탭 표시 - + // 현재 탭의 무한 스크롤 상태 const currentTabHasNext = activeTab === 'saved' ? hasSavedBooksNext : hasGroupBooksNext; const currentTabIsLoadingMore = activeTab === 'saved' ? isLoadingMoreSavedBooks : isLoadingMoreGroupBooks; diff --git a/src/components/common/MainHeader.tsx b/src/components/common/MainHeader.tsx index a2aaf386..29da8d20 100644 --- a/src/components/common/MainHeader.tsx +++ b/src/components/common/MainHeader.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import headerLogo from '../../assets/header/header-logo.svg'; -import groupDoneLogo from '../../assets/header/group-done.svg'; import findUserLogo from '../../assets/header/findUser.svg'; import bellLogo from '../../assets/header/bell.svg'; import bellExistLogo from '../../assets/header/exist-bell.svg'; @@ -40,11 +39,10 @@ const MainHeader = ({ type, leftButtonClick, rightButtonClick }: MainHeaderProps - + {type === 'home' && ( + + )} + { +const ConfirmModal = ({ + title, + disc, + onConfirm, + onClose, + confirmText = '예', + cancelText = '아니요', +}: ConfirmModalProps) => { + const handleContainerClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + return ( - +
{title}
diff --git a/src/components/common/Modal/PopupContainer.tsx b/src/components/common/Modal/PopupContainer.tsx index b978b525..3c35d617 100644 --- a/src/components/common/Modal/PopupContainer.tsx +++ b/src/components/common/Modal/PopupContainer.tsx @@ -41,7 +41,7 @@ const PopupContainer = () => { switch (popupType) { case 'confirm-modal': return ( - + ); diff --git a/src/components/group/CompletedGroupModal.tsx b/src/components/group/CompletedGroupModal.tsx deleted file mode 100644 index adb7740a..00000000 --- a/src/components/group/CompletedGroupModal.tsx +++ /dev/null @@ -1,195 +0,0 @@ -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 { getMyProfile } from '@/api/users/getMyProfile'; -import { colors, typography } from '@/styles/global/global'; -import { useNavigate } from 'react-router-dom'; - -interface CompletedGroupModalProps { - onClose: () => void; -} - -const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { - const navigate = useNavigate(); - const [rooms, setRooms] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [nickname, setNickname] = useState(''); - - useEffect(() => { - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, []); - - const convertRoomToGroup = (room: Room): Group => { - return { - id: room.roomId.toString(), - title: room.roomName, - userName: '', - participants: room.memberCount, - maximumParticipants: room.recruitCount, - coverUrl: room.bookImageUrl, - deadLine: '', - isOnGoing: false, - type: room.type, - }; - }; - - const handleGroupCardClick = (group: Group) => { - navigate(`/group/detail/joined/${group.id}`); - }; - - 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); - } - }; - - const fetchNickname = async () => { - try { - const profile = await getMyProfile(); - setNickname(profile.nickname); - } catch { - setNickname(''); - } - }; - - fetchCompletedRooms(); - fetchNickname(); - }, []); - - const convertedGroups = rooms.map(convertRoomToGroup); - return ( - - - } - onLeftClick={onClose} - /> - - {nickname - ? `${nickname}님이 참여했던 모임방들을 확인해보세요.` - : '참여했던 모임방들을 확인해보세요.'} - - - {isLoading ? ( - 로딩 중... - ) : error ? ( - {error} - ) : convertedGroups.length > 0 ? ( - convertedGroups.map(group => ( - handleGroupCardClick(group)} - /> - )) - ) : ( - - 완료된 모임방이 없어요 - 아직 완료된 모임방이 없습니다. - - )} - - - - ); -}; - -export default CompletedGroupModal; - -const Text = styled.p` - font-size: ${typography.fontSize.sm}; - font-weight: ${typography.fontWeight.regular}; - color: ${colors.white}; - margin: 20px; -`; - -const Content = styled.div<{ isEmpty?: boolean }>` - display: grid; - gap: 20px; - overflow-y: ${({ isEmpty }) => (isEmpty ? 'visible' : 'auto')}; - padding: 0 20px; - grid-template-columns: 1fr; - margin-bottom: 70px; - - @media (min-width: 584px) { - grid-template-columns: 1fr 1fr; - } - - & > *:only-child { - grid-column: 1 / -1; - } - &::-webkit-scrollbar { - display: none; - } - -ms-overflow-style: none; - scrollbar-width: none; -`; - -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: 70%; - position: absolute; - left: 50%; - transform: translateX(-50%); -`; - -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 79700a0a..797750b4 100644 --- a/src/components/group/GroupCard.tsx +++ b/src/components/group/GroupCard.tsx @@ -13,10 +13,14 @@ interface Props { onClick?: () => void; isFirstCard?: boolean; isPublic?: boolean; + isCompleted?: boolean; } export const GroupCard = forwardRef( - ({ group, isOngoing, type = 'main', isRecommend = false, onClick, isFirstCard }, ref) => { + ( + { group, isOngoing, type = 'main', isRecommend = false, onClick, isFirstCard, isCompleted }, + ref, + ) => { return ( @@ -33,9 +37,13 @@ export const GroupCard = forwardRef( people

{group.participants}

- / {group.maximumParticipants}명 + {!isCompleted && ( + / {group.maximumParticipants}명 + )} + {isCompleted && }
- {(type !== 'modal' || group.type !== 'expired') && + {!isCompleted && + (type !== 'modal' || group.type !== 'expired') && (isOngoing === true ? ( {group.deadLine} 종료 @@ -138,7 +146,7 @@ const Bottom = styled.div` const Participant = styled.div<{ isRecommend: boolean }>` display: flex; align-items: center; - gap: 6px; + gap: 4px; color: ${colors.white}; font-size: ${typography.fontSize.xs}; font-weight: ${typography.fontWeight.medium}; diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index 6605ece2..75351bd9 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -21,7 +21,7 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { }; }, []); const navigate = useNavigate(); - const [selected, setSelected] = useState<'진행중' | '모집중' | ''>(''); + const [selected, setSelected] = useState<'진행중' | '모집중' | '완료' | ''>(''); const [rooms, setRooms] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -57,7 +57,9 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { ? 'playing' : selected === '모집중' ? 'recruiting' - : 'playingAndRecruiting'; + : selected === '완료' + ? 'expired' + : 'playingAndRecruiting'; const response = await getMyRooms(roomType, null); @@ -92,7 +94,9 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { ? 'playing' : selected === '모집중' ? 'recruiting' - : 'playingAndRecruiting'; + : selected === '완료' + ? 'expired' + : 'playingAndRecruiting'; const res = await getMyRooms(roomType, nextCursor); if (res.isSuccess) { @@ -119,40 +123,6 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { } }; - useEffect(() => { - const fetchRooms = async () => { - try { - setIsLoading(true); - setError(null); - setNextCursor(null); - setIsLast(false); - - const roomType: RoomType = - selected === '진행중' - ? 'playing' - : selected === '모집중' - ? 'recruiting' - : 'playingAndRecruiting'; - - const res = await getMyRooms(roomType, null); - if (res.isSuccess) { - setRooms(res.data.roomList); - setNextCursor(res.data.nextCursor); - setIsLast(res.data.isLast); - } else { - setError(res.message); - } - } catch (e) { - console.log(e); - setError('방 목록을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; - - fetchRooms(); - }, [selected]); - useEffect(() => { const tryFill = async () => { if (!contentRef.current || isLast) return; @@ -169,6 +139,7 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { } }; tryFill(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [rooms, nextCursor, isLast]); useEffect(() => { @@ -180,7 +151,9 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { const convertedGroups = rooms.map(convertRoomToGroup); const handleGroupCardClick = (group: Group) => { - if (selected === '모집중') { + if (selected === '완료') { + navigate(`/group/detail/joined/${group.id}`); + } else if (selected === '모집중') { navigate(`/group/detail/${group.id}`); } else if (selected === '진행중') { navigate(`/group/detail/joined/${group.id}`); @@ -202,7 +175,7 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { /> - {(['진행중', '모집중'] as const).map(tab => ( + {(['진행중', '모집중', '완료'] as const).map(tab => ( { group={group} isOngoing={group.isOnGoing} type="modal" + isCompleted={selected === '완료'} onClick={() => handleGroupCardClick(group)} /> ))} @@ -235,14 +209,18 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { ? '진행중인 모임방이 없어요' : selected === '모집중' ? '모집중인 모임방이 없어요' - : '참여중인 모임방이 없어요'} + : selected === '완료' + ? '완료된 모임방이 없어요' + : '참여중인 모임방이 없어요'} {selected === '진행중' ? '진행중인 모임방에 참여해보세요!' : selected === '모집중' ? '모집중인 모임방에 참여해보세요!' - : '첫 번째 모임방에 참여해보세요!'} + : selected === '완료' + ? '아직 완료된 모임방이 없습니다.' + : '첫 번째 모임방에 참여해보세요!'} )} diff --git a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx index dbdc0eaf..9b94a1a3 100644 --- a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx +++ b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx @@ -1,13 +1,17 @@ import { useState, useRef, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { usePopupActions } from '@/hooks/usePopupActions'; +import { getAiUsage } from '@/api/record'; import plusIcon from '../../../assets/memory/plus.svg'; import penIcon from '../../../assets/memory/pen.svg'; import voteIcon from '../../../assets/memory/vote.svg'; +import aiIcon from '../../../assets/memory/ai.svg'; import { AddButton, DropdownContainer, DropdownItem } from './MemoryAddButton.styled'; const MemoryAddButton = () => { const navigate = useNavigate(); const { roomId } = useParams<{ roomId: string }>(); // useParams 추가 + const { openConfirm, closePopup, openSnackbar } = usePopupActions(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -52,6 +56,69 @@ const MemoryAddButton = () => { console.log('투표 생성하기 - roomId:', currentRoomId); }; + const handleAIWrite = async () => { + setIsOpen(false); + const currentRoomId = roomId || '1'; + + try { + const result = await getAiUsage(Number(currentRoomId)); + + if (result.isSuccess) { + const { recordCount, recordReviewCount } = result.data; + + // 기록이 2개 미만인 경우 에러 표시 + if (recordCount < 2) { + openSnackbar({ + message: `독후감 생성을 위해서는 최소 2개의 기록이 필요합니다. 현재 기록 개수: ${recordCount}`, + variant: 'top', + isError: true, + onClose: () => {}, + }); + return; + } + + // 잔여 횟수가 5회 이상인 경우 (이미 5회 모두 사용) + if (recordReviewCount >= 5) { + openSnackbar({ + message: '사용자의 독후감 작성 수가 5회를 초과했습니다.', + variant: 'top', + isError: true, + onClose: () => {}, + }); + return; + } + + // 모달 표시 + openConfirm({ + title: 'AI 독서감상문 생성 (Beta)', + disc: `기록장에서 작성한 기록을 기반으로
독서감상문을 생성하시겠어요?
(서비스 내 잔여 이용횟수 : ${recordReviewCount}/5)`, + confirmText: '확인', + cancelText: '취소', + onConfirm: () => { + closePopup(); + navigate(`/aiwrite/${currentRoomId}`); + console.log('AI 독서 감상문 생성 시작 - roomId:', currentRoomId); + }, + }); + } else { + openSnackbar({ + message: result.message, + variant: 'top', + isError: true, + onClose: () => {}, + }); + } + } catch (error) { + console.error('AI 이용 횟수 조회 실패:', error); + openSnackbar({ + message: 'AI 이용 횟수 조회에 실패했습니다', + variant: 'top', + isError: true, + onClose: () => {}, + }); + } + }; + return (
@@ -68,6 +135,10 @@ const MemoryAddButton = () => { 투표 생성 투표 생성 + + AI 독서 감상문 생성 + AI 독서 감상문 생성 + )}
diff --git a/src/components/pollwrite/PollCreationSection.tsx b/src/components/pollwrite/PollCreationSection.tsx index 92b5dca1..0f1eb489 100644 --- a/src/components/pollwrite/PollCreationSection.tsx +++ b/src/components/pollwrite/PollCreationSection.tsx @@ -108,7 +108,7 @@ const PollCreationSection = ({ { + const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + const { openSnackbar, openConfirm, closePopup } = usePopupActions(); + const [isLoading, setIsLoading] = useState(true); + const [aiContent, setAiContent] = useState(''); + + useEffect(() => { + const fetchAiReview = async () => { + if (!roomId) return; + + try { + const result = await createAiReview(Number(roomId)); + + if (result.isSuccess) { + setAiContent(result.data.content); + } else { + openSnackbar({ + message: result.message, + variant: 'top', + isError: true, + onClose: () => {}, + }); + navigate(`/rooms/${roomId}/memory`); + } + } catch (error) { + console.error('AI 독서감상문 생성 실패:', error); + let errorMessage = 'AI 독서감상문 생성에 실패했습니다'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + data?: { + message?: string; + }; + }; + }; + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } + } + + openSnackbar({ + message: errorMessage, + variant: 'top', + isError: true, + onClose: () => {}, + }); + navigate(`/rooms/${roomId}/memory`); + } finally { + setIsLoading(false); + } + }; + + fetchAiReview(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId]); + + const handleBackClick = () => { + openConfirm({ + title: 'AI 독서감상문 생성 (Beta)', + disc: '생성된 감상문은 다시 볼 수 없으며, 잔여 이용횟수는 차감돼요. 계속하시겠어요?', + confirmText: '확인', + cancelText: '취소', + onConfirm: () => { + closePopup(); + navigate(`/rooms/${roomId}/memory`); + }, + }); + }; + + const handleCopyToClipboard = async () => { + try { + await navigator.clipboard.writeText(aiContent); + openSnackbar({ + message: '클립보드에 복사가 완료되었어요', + variant: 'top', + onClose: () => {}, + }); + } catch (error) { + console.error('클립보드 복사 실패:', error); + openSnackbar({ + message: '복사에 실패했습니다', + variant: 'bottom', + isError: true, + onClose: () => {}, + }); + } + }; + + return ( + + } + onLeftClick={handleBackClick} + /> + + {isLoading ? ( + + + + 독서 감상문을 생성중이에요! + 조금만 기다려주세요 + + + ) : ( + + + 정보 + 내 기록과 총평을 바탕으로 생성된 감상문입니다. + + {aiContent} + 클립보드에 복사 + + )} + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + min-width: 320px; + max-width: 767px; + min-height: 100vh; + margin: 0 auto; + background-color: ${colors.black.main}; + padding-top: 56px; +`; + +const LoadingContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 20px; +`; + +const MessageContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +`; + +const Message = styled.div` + color: ${colors.white}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + text-align: center; +`; + +const SubMessage = styled.div` + color: ${colors.grey[100]}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + line-height: 20px; + text-align: center; +`; + +const ResultContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: calc(100vh - 56px); + overflow-y: auto; +`; + +const InfoBanner = styled.div` + display: flex; + align-items: center; + gap: 4px; + padding: 13px 26px; + + img { + width: 20px; + height: 20px; + } + + span { + color: ${colors.grey[200]}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + line-height: auto; + } +`; + +const ContentText = styled.div` + flex: 1; + padding: 0 26px 74px; + color: ${colors.white}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + line-height: 20px; + white-space: pre-wrap; +`; + +const CopyButton = styled.button` + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 767px; + height: 50px; + background-color: ${colors.purple.main}; + color: ${colors.white}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + border: none; + cursor: pointer; + text-align: center; + line-height: 50px; +`; + +export default AIWrite; diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx index 9a4722ee..c51b3ba6 100644 --- a/src/pages/group/CreateGroup.tsx +++ b/src/pages/group/CreateGroup.tsx @@ -246,6 +246,7 @@ const CreateGroup = () => { isOpen={isBookSearchOpen} onClose={handleBookSearchClose} onSelectBook={handleBookSelect} + showGroupTab={false} /> diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 9c43a6f8..43c3b4fb 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -8,7 +8,6 @@ import styled from '@emotion/styled'; import { RecruitingGroupCarousel, type Section } from '@/components/group/RecruitingGroupCarousel'; 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 searchChar from '../../assets/common/searchChar.svg'; @@ -32,7 +31,6 @@ const convertRoomItemToGroup = ( const Group = () => { const navigate = useNavigate(); const [isMyGroupModalOpen, setIsMyGroupModalOpen] = useState(false); - const [isCompletedGroupModalOpen, setIsCompletedGroupModalOpen] = useState(false); const [sections, setSections] = useState([ { title: '최근 생성된 독서 모임방', groups: [] }, { title: '마감 임박한 독서 모임방', groups: [] }, @@ -86,9 +84,6 @@ const Group = () => { const openMyGroupModal = () => setIsMyGroupModalOpen(true); const closeMyGroupModal = () => setIsMyGroupModalOpen(false); - const openCompletedGroupModal = () => setIsCompletedGroupModalOpen(true); - const closeCompletedGroupModal = () => setIsCompletedGroupModalOpen(false); - const handleSearchBarClick = () => { navigate('/group/search'); }; @@ -108,12 +103,7 @@ const Group = () => { return ( {isMyGroupModalOpen && } - {isCompletedGroupModalOpen && } - + diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ff870356..d3fbb0e1 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -40,6 +40,7 @@ import Notice from './notice/Notice'; import ParticipatedGroupDetail from './groupDetail/ParticipatedGroupDetail'; import GroupMembers from './groupMembers/GroupMembers'; import Guide from './Guide'; +import AIWrite from './aiwrite/AIWrite'; const Router = () => { const router = createBrowserRouter( @@ -64,6 +65,7 @@ const Router = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/stores/usePopupStore.ts b/src/stores/usePopupStore.ts index acd104e6..0e03e11f 100644 --- a/src/stores/usePopupStore.ts +++ b/src/stores/usePopupStore.ts @@ -14,6 +14,8 @@ export interface ConfirmModalProps { disc: string; onConfirm?: () => void; onClose?: () => void; + confirmText?: string; + cancelText?: string; } export interface MoreMenuProps { diff --git a/src/types/record.ts b/src/types/record.ts index db0c0995..a7295a4d 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -72,6 +72,18 @@ export interface UpdateVoteData { roomId: number; // 방 ID } +// AI 독서감상문 생성 응답 데이터 타입 +export interface CreateAiReviewData { + content: string; // 생성된 독서감상문 내용 + count: number; // 잔여 이용 횟수 +} + +// AI 이용 횟수 조회 응답 데이터 타입 +export interface AiUsageData { + recordReviewCount: number; // AI 독서감상문 작성 가능 횟수 + recordCount: number; // 기록 작성 횟수 +} + // 공통 API 응답 타입 export interface ApiResponse { isSuccess: boolean;