diff --git a/src/app/routes.ts b/src/app/routes.ts index 76e6b905..83d97e1d 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -8,6 +8,7 @@ export default [ route('sell', 'routes/(main)/sell.tsx'), route('sell/confirm', 'routes/(main)/sell.confirm.tsx'), route('mypage', 'routes/(main)/mypage.tsx'), + route('chatbot', 'routes/(main)/chatbot.tsx'), route('mypage/settings', 'routes/(main)/mypage.settings.tsx'), route('mypage/profile', 'routes/(main)/mypage.profile.tsx'), ]), diff --git a/src/app/routes/(main)/chatbot.tsx b/src/app/routes/(main)/chatbot.tsx new file mode 100644 index 00000000..68c46292 --- /dev/null +++ b/src/app/routes/(main)/chatbot.tsx @@ -0,0 +1,3 @@ +import { ChatbotPage } from '@pages/chatbot'; + +export default ChatbotPage; diff --git a/src/pages/chatbot/index.ts b/src/pages/chatbot/index.ts new file mode 100644 index 00000000..bd7ab788 --- /dev/null +++ b/src/pages/chatbot/index.ts @@ -0,0 +1 @@ +export { default as ChatbotPage } from './ui/ChatbotPage'; diff --git a/src/pages/chatbot/model/types.ts b/src/pages/chatbot/model/types.ts new file mode 100644 index 00000000..ff21552e --- /dev/null +++ b/src/pages/chatbot/model/types.ts @@ -0,0 +1,10 @@ +export type ChatMessageStatus = 'loading' | 'done'; + +export type ChatMessageRole = 'bot' | 'user'; + +export type ChatMessage = { + id: string; + role: ChatMessageRole; + content: string; + status?: ChatMessageStatus; +}; diff --git a/src/pages/chatbot/ui/ChatMessageList.tsx b/src/pages/chatbot/ui/ChatMessageList.tsx new file mode 100644 index 00000000..6718a55a --- /dev/null +++ b/src/pages/chatbot/ui/ChatMessageList.tsx @@ -0,0 +1,41 @@ +import { ChatBubble } from '@shared/ui/ChatBubble'; +import type { ChatMessage } from '../model/types'; + +type ChatMessageListProps = { + messages: ChatMessage[]; +}; + +export const ChatMessageList = ({ messages }: ChatMessageListProps) => { + if (messages.length === 0) { + return null; + } + + return ( +
+ {messages.map((message) => { + const isBot = message.role === 'bot'; + if (isBot) { + return ( +
+ {message.status === 'loading' ? ( +
+ + + +
+ ) : ( + + )} +
+ ); + } + + return ( +
+ +
+ ); + })} +
+ ); +}; diff --git a/src/pages/chatbot/ui/ChatbotPage.tsx b/src/pages/chatbot/ui/ChatbotPage.tsx new file mode 100644 index 00000000..88e48cab --- /dev/null +++ b/src/pages/chatbot/ui/ChatbotPage.tsx @@ -0,0 +1,110 @@ +import { Logo4 } from '@shared/assets/logo'; +import { ChatInput } from '@shared/ui/ChatInput'; +import { useEffect, useRef, useState } from 'react'; +import { ChatMessageList } from './ChatMessageList'; +import type { ChatMessage } from '../model/types'; + +const INITIAL_BOT_MESSAGE = `루핏이 예상 수리비를 빠르게 계산해드릴게요. 아래 3가지만 알려주세요. +(견적은 추정치이며 실제 비용은 수리점/부품/상태에 따라 달라질 수 있어요.) + +기종: 예) 아이폰 15, 갤럭시 S23 + +어디가 고장났는지: 예) 액정/배터리/카메라/후면/충전/침수 + +보험/케어 가입 여부: 예) 애플케어 O/X, 통신사 보험 O/X + +가능하면\u00A0원하는\u00A0방향도\u00A0한\u00A0줄로\u00A0적어주세요:\u00A0“정품\u00A0우선”\u00A0/\u00A0“최대한\u00A0저렴하\u2060게”\u00A0/\u00A0“빨리” + +예시) “아이폰 15, 액정 깨짐, 애플케어 X, 최대한 저렴하게” + +`; + +const ChatbotPage = () => { + const [message, setMessage] = useState(''); + const [messages, setMessages] = useState(() => [ + { + id: 'bot-initial', + role: 'bot' as const, + content: INITIAL_BOT_MESSAGE, + status: 'done', + }, + ]); + const replyTimeoutRef = useRef(null); + const endOfMessagesRef = useRef(null); + + useEffect(() => { + return () => { + if (replyTimeoutRef.current) { + window.clearTimeout(replyTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + endOfMessagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [messages]); + + const handleSend = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return; + } + + const userId = `user-${Date.now()}`; + const botId = `bot-${Date.now()}`; + setMessages((prev) => [ + ...prev, + { id: userId, role: 'user', content: trimmed, status: 'done' }, + { id: botId, role: 'bot', content: '답변을 준비 중이에요...', status: 'loading' }, + ]); + setMessage(''); + + if (replyTimeoutRef.current) { + window.clearTimeout(replyTimeoutRef.current); + } + replyTimeoutRef.current = window.setTimeout(() => { + setMessages((prev) => + prev.map((item) => + item.id === botId + ? { + ...item, + status: 'done', + content: '입력하신 내용으로 예상 수리비를 준비할게요.\n필요하면 기종/증상 상세를 더 알려주세요!', + } + : item + ) + ); + }, 800); + }; + + return ( +
+
+
+
+
+
+
+

루핏봇

+
+ +
+
+
+
+ +
+
+
+
+
+ ); +}; + +export default ChatbotPage; diff --git a/src/shared/assets/logo/index.ts b/src/shared/assets/logo/index.ts index 66828455..e4071159 100644 --- a/src/shared/assets/logo/index.ts +++ b/src/shared/assets/logo/index.ts @@ -1,5 +1,6 @@ import Logo from './logo.svg?react'; import Logo2 from './logo2.svg?react'; import Logo3 from './logo3.svg?react'; +import Logo4 from './logo4.svg?react'; -export { Logo, Logo2, Logo3 }; +export { Logo, Logo2, Logo3, Logo4 }; diff --git a/src/shared/ui/ChatBubble/ChatBubble.variants.ts b/src/shared/ui/ChatBubble/ChatBubble.variants.ts index 12b55b57..c1edf249 100644 --- a/src/shared/ui/ChatBubble/ChatBubble.variants.ts +++ b/src/shared/ui/ChatBubble/ChatBubble.variants.ts @@ -6,11 +6,11 @@ export const chatBubbleVariants = tv({ bubble: [ 'inline-flex', 'items-center', - 'justify-center', + 'justify-start', 'min-h-[60px]', 'px-[31px]', 'py-[18px]', - 'rounded-(--radius-l)', + 'rounded-[var(--radius-l)]', 'max-w-[559px]', ], text: ['typo-body-1', 'whitespace-pre-wrap', 'break-words'], @@ -31,6 +31,12 @@ export const chatBubbleVariants = tv({ text: ['text-gray-900'], meta: ['order-last'], }, + chatbotNotice: { + root: ['justify-start'], + bubble: ['bg-gray-100', 'max-w-[720px]'], + text: ['text-gray-900', 'whitespace-pre-line', 'break-keep'], + meta: ['order-last'], + }, }, },