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
1 change: 1 addition & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]),
Expand Down
3 changes: 3 additions & 0 deletions src/app/routes/(main)/chatbot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ChatbotPage } from '@pages/chatbot';

export default ChatbotPage;
1 change: 1 addition & 0 deletions src/pages/chatbot/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ChatbotPage } from './ui/ChatbotPage';
10 changes: 10 additions & 0 deletions src/pages/chatbot/model/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
41 changes: 41 additions & 0 deletions src/pages/chatbot/ui/ChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full flex-col items-end gap-[64px]">
{messages.map((message) => {
const isBot = message.role === 'bot';
if (isBot) {
return (
<div key={message.id} className="flex w-full justify-start">
{message.status === 'loading' ? (
<div className="inline-flex min-h-[60px] items-center justify-start gap-[var(--spacing-xxxs)] rounded-(--radius-l) bg-gray-100 px-[31px] py-[18px]">
<span className="h-[6px] w-[6px] animate-bounce rounded-full bg-gray-400 [animation-delay:-0.2s]" />
<span className="h-[6px] w-[6px] animate-bounce rounded-full bg-gray-400 [animation-delay:-0.1s]" />
<span className="h-[6px] w-[6px] animate-bounce rounded-full bg-gray-400" />
</div>
) : (
<ChatBubble message={message.content} variant="chatbotNotice" />
)}
</div>
);
}

return (
<div key={message.id} className="flex w-full items-end justify-end">
<ChatBubble message={message.content} variant="sender" className="w-auto" />
</div>
);
})}
</div>
);
};
110 changes: 110 additions & 0 deletions src/pages/chatbot/ui/ChatbotPage.tsx
Original file line number Diff line number Diff line change
@@ -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<ChatMessage[]>(() => [
{
id: 'bot-initial',
role: 'bot' as const,
content: INITIAL_BOT_MESSAGE,
status: 'done',
},
]);
const replyTimeoutRef = useRef<number | null>(null);
const endOfMessagesRef = useRef<HTMLDivElement | null>(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 (
<div className="w-full bg-white">
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col bg-white xl:min-h-[1024px]">
<main className="flex flex-1 flex-col px-[var(--margin-l)] md:px-[var(--spacing-xxxl)] xl:px-0">
<section className="mx-auto flex w-full max-w-full flex-col items-start gap-[19px] self-stretch pb-[140px] xl:max-w-[1200px]">
<div className="flex items-center gap-[var(--spacing-m)] md:gap-[var(--spacing-xl)] xl:gap-[36px]">
<div className="flex h-[64px] w-[64px] flex-shrink-0 flex-col items-center justify-center gap-[10px] rounded-full bg-black pt-[var(--padding-xl)] pr-[10px] pb-[var(--padding-xl)] pl-[14px] md:h-[72px] md:w-[72px] md:pt-[26px] md:pr-[11px] md:pb-[28px] md:pl-[15px] xl:h-[80px] xl:w-[80px] xl:pt-[31px] xl:pr-[13px] xl:pb-[33px] xl:pl-[17px]">
<Logo4 className="h-[80px] w-[80px] flex-shrink-0" aria-hidden="true" />
</div>
<h1 className="typo-title-2 text-black">루핏봇</h1>
</div>
<ChatMessageList messages={messages} />
<div ref={endOfMessagesRef} className="scroll-mb-[96px]" />
</section>
<div className="fixed inset-x-0 bottom-[var(--margin-s)] z-50 px-[var(--margin-l)] md:px-[var(--spacing-xxxl)] xl:px-[120px]">
<div className="mx-auto w-full max-w-full xl:max-w-[1200px]">
<ChatInput
placeholder="무슨 견적을 원하시나요?"
value={message}
onChange={setMessage}
onSend={handleSend}
/>
</div>
</div>
</main>
</div>
</div>
);
};

export default ChatbotPage;
3 changes: 2 additions & 1 deletion src/shared/assets/logo/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
10 changes: 8 additions & 2 deletions src/shared/ui/ChatBubble/ChatBubble.variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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'],
},
},
},

Expand Down
Loading