Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/icons/book-snap/loading.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions src/api/bookie.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// utils/chatAPI.ts
import instance from './instance';

// 한줄 리뷰 등록 (Spring 서버를 통해 FastAPI 프록시로 전달)
export const sendMessageToChatAPI = async (message: string) => {
try {
const response = await instance.post('/bookie/chat', { message: message });

if (response.status === 200) {
return response.data;
} else {
throw new Error('Server responded with error');
}
} catch (error) {
console.error('❌ chat API 호출 실패:', error);
throw error;
}
};
38 changes: 38 additions & 0 deletions src/constants/bookieLoadingMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// utils/bookieLoadingMessages.ts

export const bookieLoadingMessages = [
// 📖 책 팁 / 독서 팁
'📚 하루 10분 독서만 해도 1년에 책 12권은 읽을 수 있어요!',
'🕯️ 자기 전 15분 독서는 스트레스를 68% 줄여준대요.',
'📖 집중이 안 될 땐 10분짜리 짧은 독립출판물부터 시작해보세요.',
'📎 종이책을 읽을 땐 포스트잇을 곁에 두는 습관, 생각 정리에 좋아요.',
'📱 전자책도 좋아요, 하지만 눈을 쉬게 하려면 종이책도 가끔씩은 꼭!',

// 🎯 문학 퀴즈 / 정보
'퀴즈! 세계에서 가장 많이 팔린 책은 무엇일까요? (힌트: 성경 말고!)',
'퀴즈! 해리포터 1권의 원제는 무엇일까요?',
'작가가 27번 거절당하고도 출간한 책, 알고 있나요? — 《해리 포터》 시리즈!',
'미국의 국민 시인이라 불리는 작가는 누구일까요? (힌트: ‘풀잎 위에서’)',
'‘책의 날’은 매년 4월 23일! 세르반테스와 셰익스피어가 같은 날 세상을 떠났어요.',

// 🗂️ 독립출판/인디북스 정보
'독립출판물은 보통 작가가 직접 기획, 집필, 디자인, 제작까지 한답니다.',
'부키 팁! 독립서점마다 특별한 큐레이션 책장이 있는 거, 알고 계셨나요?',
'‘지금 여기, 내가 만든 책’ — 독립출판의 핵심은 바로 이 말이에요.',
'독립출판물은 1쇄 100부도 가능해요. 나만의 책 만들기도 도전해보세요!',
'서울에는 약 100여 개의 독립서점이 존재해요. 부키가 추천해드릴까요?',

// 💡 랜덤 흥미 정보 (재미용)
'지구상에서 가장 오래된 책은 기원전 2400년의 ‘죽은 자의 서’랍니다.',
'고서 수집가들 사이에서 가장 비싼 책은 3천억 원이 넘게 거래되기도 했어요!',
'세계 최초의 소설은 일본의 《겐지 이야기》라는 거, 알고 계셨나요?',
'📖 부키는 지금 책향기가 나는 데이터 속으로 잠수 중입니다…',
'책도 사람도, 겉보다 속이 더 중요하답니다. 😊',

'《책방》이라는 단어는 일본식 한자어예요. 원래는 ‘서점’이 맞답니다!',
'세상에서 가장 오래된 도서관은 기원전 7세기 이집트에 있었어요.',
'어떤 책은 종이를 직접 염색해서 만드는 ‘리소’ 인쇄를 써요!',
'책을 선물하면 기억에 오래 남는대요. 이유는 ‘시간을 주는 거니까’.',
'📦 독립출판은 때로 택배 상자 속 한 줄 편지로 완성돼요.',
'누군가는 밤새워 만든 40쪽짜리 책, 당신이 펼칠 차례예요.',
];
108 changes: 92 additions & 16 deletions src/pages/Bookie.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,74 @@
import Header from '../components/Common/Header';
import bookie from '../../public/icons/bookie/bookie.png';
import MessageBox from '../components/Bookie/MessageBox';
import { useEffect, useRef, useState } from 'react';
import Input from '../components/Bookie/Input';
import { FaRegArrowAltCircleUp } from 'react-icons/fa';
import { FaAngleLeft } from 'react-icons/fa6';
import { IoCloseOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { sendMessageToChatAPI } from '../api/bookie.api';
import { MdBookmarkAdd } from 'react-icons/md';
import toast from 'react-hot-toast';
import { pickBook } from '../api/booksnap.api';
import { bookieLoadingMessages } from '../constants/bookieLoadingMessages';

type MessageType = {
text: string;
type: 'system' | 'user';
books?: BookCardType[];
};

type BookCardType = {
title: string;
bookId: string;
bookImageUrl: string;
};

const Bookie = () => {
const endOfMessages = useRef<HTMLDivElement | null>(null);
const [input, setInput] = useState<string>('');
const nav = useNavigate();
const [isComposing, setIsComposing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState<string>('');
const userName = '이구역독서짱';
const [systemRes, setSystemRes] = useState<MessageType[]>([
{
text: '안녕하세요! 이구역 독서짱님이 좋아하실만한책을 추천해드리는 Bookie입니다! 더 많은 정보를 알려주시면, 책을 찾아드릴게요.',
text: `안녕하세요! ${userName}님이 좋아하실만한책을 추천해드리는 Bookie입니다! 더 많은 정보를 알려주시면, 책을 찾아드릴게요.`,
type: 'system',
},
]);
const userName = '이구역 독서짱';

useEffect(() => {
if (endOfMessages.current) {
endOfMessages.current.scrollIntoView({ behavior: 'smooth' });
}
}, [systemRes]);

const sendMessage = () => {
// 메세지 보내기
const sendMessage = async () => {
if (!input.trim() || isComposing) return;
if (input.trim() !== '') {
const userMessage: MessageType = { text: input, type: 'user' };
setSystemRes((prevMessages) => [...prevMessages, userMessage]);

setInput('');
const userMessage: MessageType = { text: input, type: 'user' };
setSystemRes((prev) => [...prev, userMessage]); // 사용자 메시지 먼저 출력
setInput('');

const random = bookieLoadingMessages[Math.floor(Math.random() * bookieLoadingMessages.length)];
setLoadingMessage(random);
setIsLoading(true);

try {
const reply = await sendMessageToChatAPI(input);
const systemMessage: MessageType = { text: reply.message, type: 'system', books: reply.books };
setSystemRes((prev) => [...prev, systemMessage]); // GPT 응답 추가
} catch (error) {
const errorMessage: MessageType = {
text: '서버와 연결할 수 없습니다.',
type: 'system',
};
setSystemRes((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
setLoadingMessage('');
}
};

Expand All @@ -53,10 +83,25 @@ const Bookie = () => {
}
};

const handlePickBook = (book: BookCardType) => {
console.log(book);
if (!localStorage.getItem('accessToken')) {
toast.error('로그인이 필요한 서비스입니다.');
} else {
pickBook(book.bookId).then((data) => {
if (data?.success) {
toast.success(`${book.title}을(를) 책장에 담았어요!`);
} else {
toast.error(`${data?.message}`);
}
});
}
};

return (
<div className="mt-[70px] flex flex-col">
{/* 헤더 */}
<div className="fixed left-0 right-0 top-0 m-auto w-full max-w-[500px]">
<div className="fixed left-0 right-0 top-0 z-30 m-auto w-full max-w-[500px]">
<div className="flex items-center bg-bg px-2 py-3">
<div className="flex cursor-pointer items-center justify-center p-2.5" onClick={() => nav('/')}>
<IoCloseOutline size={30} className="stroke-white" />
Expand All @@ -69,7 +114,7 @@ const Bookie = () => {
</div>
</div>
{/* 내용 */}
<div className="flex flex-col">
<div className="flex flex-col overflow-y-auto">
{/* 설명 */}
<div className="px-8 py-2 text-[14px] font-light">
<span className="text-pink">{userName}</span>
Expand All @@ -78,29 +123,60 @@ const Bookie = () => {
책과 서점을 추천해드릴게요!
</span>
</div>

<div className="flex w-full justify-center bg-gradient-to-b from-[#302D2D] to-[#C0E0D8]">
<img src={bookie} className="w-24 pt-2" />
</div>
{/* 채팅구역 */}
<div
className="pointer-events-auto z-10 my-10 flex max-h-[80%] w-full flex-col gap-3 self-end overflow-y-auto px-8"
className="pointer-events-auto z-10 mb-20 mt-10 flex max-h-[80%] w-full flex-col gap-3 self-end"
onWheel={(e) => e.stopPropagation()} // 휠 이벤트 차단
>
{systemRes.map((msg, index) => (
<MessageBox key={index} text={msg.text} type={msg.type} />
<div key={index} className="flex flex-col gap-2 px-6">
<MessageBox text={msg.text} type={msg.type} />
{msg.books && msg.books.length > 0 && (
<div className="mt-1 flex flex-col gap-3">
<div className="flex gap-4">
{msg.books.map((book, idx) => (
<div key={idx} className="flex flex-col items-center rounded-lg shadow-md">
<div className="relative">
<img
src={book.bookImageUrl}
alt={book.title}
className="mb-2 h-36 w-24 rounded-md object-cover"
/>
<button
className="absolute bottom-4 right-2 rounded-full bg-white p-1 shadow-md"
onClick={() => handlePickBook(book)}
>
<MdBookmarkAdd className="h-4 w-4 text-gray-500" />
</button>
</div>
<p className="text-center text-sm font-medium text-white">{book.title}</p>
</div>
))}
</div>
</div>
)}
</div>
))}
{isLoading && (
<div className="flex flex-col items-center justify-center py-2 text-white">
<h3 className="text-[15px]">부키가 책을 고르러 작은 서점 골목으로 들어갔어요...📚</h3>
<p className="animate-pulse text-[12px] italic">{loadingMessage}</p>
</div>
)}
<div ref={endOfMessages}></div>
</div>
</div>
{/* 입력창 */}
<div className="fixed bottom-8 m-auto flex w-full max-w-[500px] items-center justify-between gap-2 pl-9 pr-[27px]">
<div className="fixed bottom-0 z-30 m-auto flex w-full max-w-[500px] items-center justify-between gap-2 bg-bg pb-8 pl-9 pr-[27px]">
<Input input={input} setInput={setInput} onSend={sendMessage} onComposition={handleComposition} />
<div className="flex h-11 w-11 items-center justify-center drop-shadow-md">
{input == '' ? (
<FaRegArrowAltCircleUp className="h-full w-full fill-white" />
) : (
<div className="bg-pink flex h-9 w-9 items-center justify-center rounded-full">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-pink">
<FaRegArrowAltCircleUp className="h-7 w-7 fill-bg" />
</div>
)}
Expand Down
9 changes: 8 additions & 1 deletion src/pages/Booksnap/BookSnap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import Loading from '../Loading';
import WriteButton from '../../components/Booksnap/WriteButton';
import { getReview } from '../../api/booksnap.api';
import Toast from '../../components/Common/Toast';
import { useNavigate } from 'react-router-dom';
import BooksnapHeader from '../../components/Header/BooksnapHeader';

const BookSnap = () => {
Expand All @@ -17,16 +16,20 @@ const BookSnap = () => {
const [isBottom, setIsBottom] = useState<boolean>(false);
const mainRef = useRef<HTMLDivElement>(null);
const isLastRef = useRef<boolean>(false);
const [isLoading, setIsLoading] = useState(false);

// 리뷰 목록 받아오기
const getReviews = async () => {
setIsLoading(true);
try {
const data = await getReview(filter, page);
setReview((prev) => (page === 1 ? data.data.booksnapPreview : [...prev, ...data.data.booksnapPreview]));
setIsLast(data.data.last);
setIsBottom(false);
} catch (error) {
console.error('리뷰를 불러오는 중 에러 발생:', error);
} finally {
setIsLoading(false);
}
};

Expand Down Expand Up @@ -83,6 +86,10 @@ const BookSnap = () => {
}
}, []);

if (isLoading) {
return <Loading text="리뷰 목록을 불러오는 중입니다!" />;
}

if (!review) {
return <Loading text="데이터를 불러오는 데 실패했습니다!" />;
}
Expand Down
8 changes: 8 additions & 0 deletions src/pages/Booksnap/CreateBook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AddBookstore from '../../components/Booksnap/AddBookstore';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import { postIndepBook } from '../../api/booksnap.api';
import cropImage from '../../utils/cropImage';
import Loading from '../Loading';

export type Option = {
bookstoreId: number;
Expand All @@ -28,8 +29,10 @@ const CreateBook = () => {
const [previewList, setPreviewList] = useState<string[]>([]); // 미리보기용 URL 저장

const [selectedBookstores, setSelectedBookstores] = useState<readonly Option[]>([]);
const [isLoading, setIsLoading] = useState(false);

const handleReviewPost = async () => {
setIsLoading(true);
const bookstoreIds = selectedBookstores.map((b) => b.bookstoreId);

const payload = {
Expand All @@ -47,6 +50,7 @@ const CreateBook = () => {
imageToUpload = await cropImage(url, 80, 120); // 자른 File 반환
}
postIndepBook(imageToUpload, payload).then(() => {
setIsLoading(false);
nav('/booksnap');
});
};
Expand All @@ -60,6 +64,10 @@ const CreateBook = () => {
}
};

if (isLoading) {
return <Loading text="리뷰를 등록중입니다!" />;
}

return (
<div className="flex h-full flex-col bg-bg pb-[50px] pt-[70px]">
{/* 헤더 */}
Expand Down
9 changes: 9 additions & 0 deletions src/pages/Booksnap/CreateBooksnapReview2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import WritingReview from '../../components/Zip/WritingReview';
import Button from '../../components/Button/Button';
import { postBookReview } from '../../api/booksnap.api';
import Step from '../../components/Booksnap/Step';
import Loading from '../Loading';

const CreateBooksnapReview2 = () => {
const location = useLocation();
Expand All @@ -16,17 +17,25 @@ const CreateBooksnapReview2 = () => {
const [rating, setRating] = useState(0);
const [review, setReview] = useState('');

const [isLoading, setIsLoading] = useState(false);

const handleReviewPost = () => {
setIsLoading(true);
const payload = {
isbn: book.isbn,
rating: rating,
reviewText: review,
};
postBookReview('normal', payload).then((data) => {
setIsLoading(false);
nav('/booksnap');
});
};

if (isLoading) {
return <Loading text="리뷰를 등록중입니다!" />;
}

return (
<div className="flex h-full flex-col bg-bg pb-[50px] pt-[70px]">
{/* 헤더 */}
Expand Down
9 changes: 8 additions & 1 deletion src/pages/Booksnap/IndiCreateBookReview2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Button from '../../components/Button/Button';
import { postBookReview } from '../../api/booksnap.api';
import Step from '../../components/Booksnap/Step';
import AddBookstore from '../../components/Booksnap/AddBookstore';
import Loading from '../Loading';

export type Option = {
bookstoreId: number;
Expand All @@ -25,8 +26,10 @@ const IndiCreateBookReview2 = () => {
const [review, setReview] = useState('');

const [selectedBookstores, setSelectedBookstores] = useState<readonly Option[]>([]);
const [isLoading, setIsLoading] = useState(false);

const handleReviewPost = () => {
setIsLoading(true);
const bookstoreIds = selectedBookstores.map((b) => b.bookstoreId);

const payload = {
Expand All @@ -36,11 +39,15 @@ const IndiCreateBookReview2 = () => {
reviewText: review,
};
postBookReview('indep', payload).then((data) => {
console.log('리뷰 등록 성공');
setIsLoading(false);
nav('/booksnap');
});
};

if (isLoading) {
return <Loading text="리뷰를 등록중입니다!" />;
}

return (
<div className="flex h-full flex-col bg-bg pb-[50px] pt-[70px]">
{/* 헤더 */}
Expand Down
Loading