diff --git a/src/App.tsx b/src/App.tsx index e84e7e2..120390c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,3 @@ -import { type FormEvent, useState } from "react"; import { Route, Routes } from "react-router-dom"; import styles from "./App.module.css"; import { ChatWidget } from "./components/organisms"; @@ -22,38 +21,12 @@ import { SignUp, } from "./pages"; -import type { Message } from "./types/types"; - function GlobalAppSetup() { useTokenExpirationCheck(); return null; } export default function App() { - const [isChatOpen, setIsChatOpen] = useState(false); - const [messages, setMessages] = useState([ - { id: 1, text: "안녕하세요! 무엇을 도와드릴까요?", isUser: false }, - ]); - const [newMessage, setNewMessage] = useState(""); - - const handleSendMessage = (e: FormEvent) => { - e.preventDefault(); - if (!newMessage.trim()) return; - const nextId = messages.length - ? Math.max(...messages.map((m) => m.id)) + 1 - : 1; - setMessages([ - ...messages, - { id: nextId, text: newMessage, isUser: true }, - { - id: nextId + 1, - text: "죄송합니다. 지금은 상담이 불가능합니다. 상담원 연결은 평일 09:00~18:00에 가능합니다.", - isUser: false, - }, - ]); - setNewMessage(""); - }; - return (
@@ -79,14 +52,7 @@ export default function App() { } /> - setIsChatOpen(!isChatOpen)} - onChange={setNewMessage} - onSend={handleSendMessage} - /> + diff --git a/src/api/chat.ts b/src/api/chat.ts new file mode 100644 index 0000000..eada296 --- /dev/null +++ b/src/api/chat.ts @@ -0,0 +1,24 @@ +import { api } from "../lib/axios"; +import type { Product } from "../types/chat"; + +type ChatResponseData = { + chatbotMessage: string; + recommendedProducts: Product[]; +}; + +/** + * 챗봇 메시지를 백엔드로 전송하고 AI의 응답을 받아오는 함수 + * @param query 사용자가 입력한 메시지 + * @returns AI가 생성한 답변 데이터 + */ +export async function postChatMessage( + query: string, +): Promise { + const res = await api.post( + "/chat", // 👈 baseURL이 빠진 상대 경로만 사용 + { query }, // Request Body + ); + + // 👈 mainProducts.ts와 동일한 데이터 반환 구조 + return res.data; // as ChatResponseData; +} diff --git a/src/components/organisms/ChatWidget/ChatWidget.module.css b/src/components/organisms/ChatWidget/ChatWidget.module.css index faf00a2..b274b05 100644 --- a/src/components/organisms/ChatWidget/ChatWidget.module.css +++ b/src/components/organisms/ChatWidget/ChatWidget.module.css @@ -47,21 +47,6 @@ flex-direction: column; gap: 1rem; } -.msg { - padding: 0.75rem; -} -.user { - align-self: flex-end; - color: var(--color-white); - border-radius: 10px 10px 0 10px; - background-color: var(--color-black); -} -.bot { - align-self: flex-start; - border-radius: 10px 10px 10px 0; - color: var(--color-black); - background-color: var(--color-gray-200); -} .form { display: flex; gap: 0.5rem; @@ -89,3 +74,101 @@ .send:hover { background-color: var(--color-navy); } +.messageRow { + display: flex; + align-items: flex-end; /* 아바타와 말풍선 하단을 정렬 */ + margin-bottom: 12px; + gap: 8px; /* 아바타와 말풍선 사이 간격 */ +} + +/* 봇 메시지 행 스타일 */ +.botRow { + justify-content: flex-start; /* 왼쪽 정렬 */ +} + +/* 사용자 메시지 행 스타일 */ +.userRow { + justify-content: flex-end; /* 오른쪽 정렬 */ +} + +/* 봇 로고(아바타) 이미지 스타일 */ +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; /* 원형으로 만들기 */ + object-fit: cover; + background-color: var(--color-white); /* 이미지가 없을 경우를 대비한 배경색 */ +} + +/* 말풍선 기본 스타일 */ +.bubble { + max-width: 75%; + padding: 10px 14px; + border-radius: 18px; + word-break: break-word; + font-size: 0.9rem; + line-height: 1.5; +} + +/* 봇 말풍선 스타일 */ +.botBubble { + background-color: var(--color-gray-200); /* 옅은 회색 배경 */ + color: var(--color-text); + border-bottom-left-radius: 4px; +} + +/* 사용자 말풍선 스타일 */ +.userBubble { + background-color: var(--color-black); + color: var(--color-white); + border-bottom-right-radius: 4px; +} +.productContainer { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.productItem { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + border-radius: 8px; + background-color: var(--color-gray-100); + border: 1px solid var(--color-gray-300); + text-decoration: none; + color: inherit; + transition: background-color 0.2s ease; +} + +.productItem:hover { + background-color: var(--color-gray-300); +} + +.productImage { + width: 50px; + height: 50px; + border-radius: 4px; + object-fit: cover; +} + +.productInfo { + display: flex; + flex-direction: column; +} + +.productBrand { + font-size: 0.8rem; + color: var(--color-gray-800); +} + +.productName { + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} diff --git a/src/components/organisms/ChatWidget/ChatWidget.tsx b/src/components/organisms/ChatWidget/ChatWidget.tsx index a1e3fac..45acef1 100644 --- a/src/components/organisms/ChatWidget/ChatWidget.tsx +++ b/src/components/organisms/ChatWidget/ChatWidget.tsx @@ -1,27 +1,101 @@ import { MessagesSquare, Send, X } from "lucide-react"; -import { type FormEvent, useEffect, useRef } from "react"; -import type { Message } from "../../../types/types"; +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { postChatMessage } from "../../../api/chat"; +import type { Product } from "../../../types/chat"; import { Input } from "../../atoms"; import styles from "./ChatWidget.module.css"; -type Props = { - isOpen: boolean; - messages: Message[]; - newMessage: string; - onToggle: () => void; - onChange: (v: string) => void; - onSend: (e: FormEvent) => void; +type Message = { + id: number; + text: string; + isUser: boolean; + products?: Product[]; }; -export default function ChatWidget({ - isOpen, - messages, - newMessage, - onToggle, - onChange, - onSend, -}: Props) { +export default function ChatWidget() { const messagesEndRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([ + { id: 1, text: "안녕하세요! 무엇을 도와드릴까요?", isUser: false }, + ]); + const [newMessage, setNewMessage] = useState(""); + + const handleSendMessage = async (e: FormEvent) => { + e.preventDefault(); + if (!newMessage.trim()) return; + + const userMessageText = newMessage; + const nextId = messages.length + ? Math.max(...messages.map((m) => m.id)) + 1 + : 1; + + // 사용자 메시지를 화면에 즉시 추가 (UX 향상) + setMessages((prev) => [ + ...prev, + { id: nextId, text: userMessageText, isUser: true }, + ]); + setNewMessage(""); + + // "답변 생성 중..." 임시 메시지 추가 + const loadingMessageId = nextId + 1; + setMessages((prev) => [ + ...prev, + { + id: loadingMessageId, + text: "답변을 생성하고 있습니다...", + isUser: false, + }, + ]); + + try { + // 실제 API 호출 + const responseData = await postChatMessage(userMessageText); + // API 호출 성공 시, "답변 생성 중..." 메시지를 실제 AI 답변으로 교체 + setMessages((prev) => { + // "답변 생성 중..." 메시지를 필터링하여 제거 + const newMessages = prev.filter((msg) => msg.id !== loadingMessageId); + const lastId = + newMessages.length > 0 + ? Math.max(...newMessages.map((m) => m.id)) + : 0; + + // 추천 상품이 있으면 상품 메시지를 먼저 추가 + if ( + responseData.recommendedProducts && + responseData.recommendedProducts.length > 0 + ) { + newMessages.push({ + id: lastId + 1, + text: "", // 상품 메시지는 텍스트가 필요 없음 + isUser: false, + products: responseData.recommendedProducts, + }); + } + + // AI의 텍스트 답변 메시지를 추가 + newMessages.push({ + id: lastId + 2, + text: responseData.chatbotMessage, + isUser: false, + }); + + return newMessages; + }); + } catch (error) { + console.error("챗봇 메시지 전송 오류:", error); + // 에러 발생 시, "답변 생성 중..." 메시지를 에러 메시지로 교체 + setMessages((prev) => + prev.map((msg) => + msg.id === loadingMessageId + ? { + ...msg, + text: "오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + } + : msg, + ), + ); + } + }; // biome-ignore lint/correctness/useExhaustiveDependencies: intentional non-exhaustive deps for scroll behavior useEffect(() => { @@ -30,29 +104,78 @@ export default function ChatWidget({ return ( <> - {isOpen && (
-
MUSINSSAK 상담
+
MUSINSSAK  AI
- {messages.map((m) => ( -
- {m.text} -
- ))} + {messages.map((m) => + // 메시지에 products가 있고, isUser가 false인 경우 상품 목록을 렌더링 + m.products && !m.isUser ? ( +
+ {m.products.map((product) => ( + + {product.productName} +
+
+ {product.brandName} +
+
+ {product.productName} +
+
+
+ ))} +
+ ) : ( + // 그렇지 않으면 기존의 텍스트 말풍선을 렌더링 +
+ {!m.isUser && ( + chatbot logo + )} +
+ {m.text} +
+
+ ), + )}
-
+ onChange(e.target.value)} + onChange={(e) => setNewMessage(e.target.value)} placeholder="메시지를 입력하세요" className={styles.input} /> diff --git a/src/types/chat.ts b/src/types/chat.ts new file mode 100644 index 0000000..db4f513 --- /dev/null +++ b/src/types/chat.ts @@ -0,0 +1,8 @@ +export type Product = { + productId: string; + productName: string; + brandName: string; + price: number; + imageUrl: string; + productLink: string; +};