Skip to content

Commit c58729b

Browse files
authored
Merge pull request #85 from Leets-Official/84-feat/챗봇-API-연동
[Feat] 챗봇 API 연동
2 parents c9f172f + 40dba91 commit c58729b

24 files changed

Lines changed: 404 additions & 121 deletions

src/app/layout/ProtectedLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { ROUTES } from '@shared/constants';
12
import { useAuthStore } from '@shared/stores';
23
import { Navigate, Outlet } from 'react-router';
34

45
const ProtectedLayout = () => {
5-
const accessToken = useAuthStore((state) => state.accessToken);
6+
const { accessToken, _hasHydrated } = useAuthStore();
7+
8+
if (!_hasHydrated) {
9+
return null;
10+
}
611

712
if (!accessToken) {
8-
return <Navigate to="/login" replace />;
13+
return <Navigate to={ROUTES.LOGIN} replace />;
914
}
1015

1116
return <Outlet />;

src/app/layout/PublicLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { ROUTES } from '@shared/constants';
12
import { useAuthStore } from '@shared/stores';
23
import { Navigate, Outlet } from 'react-router';
34

45
const PublicLayout = () => {
5-
const accessToken = useAuthStore((state) => state.accessToken);
6+
const { accessToken, _hasHydrated } = useAuthStore();
7+
8+
if (!_hasHydrated) {
9+
return null;
10+
}
611

712
if (accessToken) {
8-
return <Navigate to="/" replace />;
13+
return <Navigate to={ROUTES.MAIN} replace />;
914
}
1015

1116
return <Outlet />;

src/app/styles/index.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
--header-height: 92px;
9898
--spacing-header-content: 54px;
9999
--spacing-header-total: calc(var(--header-height) + var(--spacing-header-content));
100+
--scrollbar-width: 0px;
100101
}
101102

102103
@layer base {
@@ -109,6 +110,30 @@
109110
color: var(--color-gray-900);
110111
}
111112

113+
/* Custom Scrollbar - html only */
114+
html::-webkit-scrollbar {
115+
width: 8px;
116+
}
117+
118+
html::-webkit-scrollbar-track {
119+
background: transparent;
120+
}
121+
122+
html::-webkit-scrollbar-thumb {
123+
background: var(--color-gray-400);
124+
border-radius: 4px;
125+
}
126+
127+
html::-webkit-scrollbar-thumb:hover {
128+
background: var(--color-gray-500);
129+
}
130+
131+
/* Firefox */
132+
html {
133+
scrollbar-width: thin;
134+
scrollbar-color: var(--color-gray-400) transparent;
135+
}
136+
112137
/* View Transitions */
113138
@keyframes view-fade-in {
114139
from {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const ERROR_MESSAGES = {
2+
DEFAULT: '메시지 전송에 실패했습니다. 다시 시도해주세요.',
3+
RATE_LIMIT: '질문 횟수를 초과했습니다. 잠시 후 다시 시도해주세요.',
4+
API_CONNECTION: '서비스 연결에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.',
5+
API_PARSING: '응답 처리 중 오류가 발생했습니다. 다시 시도해주세요.',
6+
} as const;
7+
8+
export const INITIAL_BOT_MESSAGE = `루핏이 예상 수리비를 빠르게 계산해드릴게요. 아래 3가지만 알려주세요.
9+
(견적은 추정치이며 실제 비용은 수리점/부품/상태에 따라 달라질 수 있어요.)
10+
11+
기종: 예) 아이폰 15, 갤럭시 S23
12+
13+
어디가 고장났는지: 예) 액정/배터리/카메라/후면/충전/침수
14+
15+
보험/케어 가입 여부: 예) 애플케어 O/X, 통신사 보험 O/X
16+
17+
가능하면 원하는 방향도 한 줄로 적어주세요: "정품 우선" / "최대한 저렴하게" / "빨리"
18+
19+
예시) "아이폰 15, 액정 깨짐, 애플케어 X, 최대한 저렴하게"
20+
21+
`;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useChatHistoryQuery, useSendMessageMutation } from '@shared/apis/chatbot';
2+
import { AxiosError } from 'axios';
3+
import { ERROR_MESSAGES, INITIAL_BOT_MESSAGE } from './constants';
4+
import type { ChatMessage } from './types';
5+
6+
const getErrorMessage = (error: unknown): string => {
7+
if (error instanceof AxiosError) {
8+
const serverMessage = error.response?.data?.message;
9+
if (serverMessage) {
10+
return serverMessage;
11+
}
12+
13+
const status = error.response?.status;
14+
if (status === 429) {
15+
return ERROR_MESSAGES.RATE_LIMIT;
16+
}
17+
if (status === 502) {
18+
return ERROR_MESSAGES.API_CONNECTION;
19+
}
20+
}
21+
22+
return ERROR_MESSAGES.DEFAULT;
23+
};
24+
25+
export const useChatMessages = () => {
26+
const { data: history, isLoading: isHistoryLoading } = useChatHistoryQuery();
27+
const sendMutation = useSendMessageMutation();
28+
29+
const messages: ChatMessage[] = (() => {
30+
if (!history || history.length === 0) {
31+
return [
32+
{
33+
id: 'bot-initial',
34+
role: 'bot' as const,
35+
content: INITIAL_BOT_MESSAGE,
36+
status: 'done',
37+
},
38+
];
39+
}
40+
41+
return history.map((item, index) => ({
42+
id: `history-${index}`,
43+
role: item.role === 'User' ? ('user' as const) : ('bot' as const),
44+
content: item.message,
45+
status: 'done' as const,
46+
}));
47+
})();
48+
49+
const displayMessages: ChatMessage[] = (() => {
50+
if (!sendMutation.isPending) {
51+
return messages;
52+
}
53+
54+
const pendingUserMessage: ChatMessage = {
55+
id: 'pending-user',
56+
role: 'user',
57+
content: sendMutation.variables ?? '',
58+
status: 'done',
59+
};
60+
61+
const loadingBotMessage: ChatMessage = {
62+
id: 'pending-bot',
63+
role: 'bot',
64+
content: '',
65+
status: 'loading',
66+
};
67+
68+
return [...messages, pendingUserMessage, loadingBotMessage];
69+
})();
70+
71+
const handleSend = (value: string) => {
72+
const trimmed = value.trim();
73+
if (!trimmed || sendMutation.isPending) {
74+
return;
75+
}
76+
77+
sendMutation.mutate(trimmed);
78+
};
79+
80+
const errorMessage = sendMutation.isError ? getErrorMessage(sendMutation.error) : null;
81+
82+
return {
83+
displayMessages,
84+
isHistoryLoading,
85+
errorMessage,
86+
handleSend,
87+
};
88+
};

src/pages/chatbot/ui/ChatbotPage.tsx

Lines changed: 11 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,21 @@
11
import { Logo4 } from '@shared/assets/logo';
22
import { ChatInput } from '@shared/ui/ChatInput';
3-
import { useEffect, useRef, useState } from 'react';
3+
import { LoadingFallback } from '@shared/ui/LoadingFallback';
4+
import { useEffect, useRef } from 'react';
45
import { ChatMessageList } from './ChatMessageList';
5-
import type { ChatMessage } from '../model/types';
6-
7-
const INITIAL_BOT_MESSAGE = `루핏이 예상 수리비를 빠르게 계산해드릴게요. 아래 3가지만 알려주세요.
8-
(견적은 추정치이며 실제 비용은 수리점/부품/상태에 따라 달라질 수 있어요.)
9-
10-
기종: 예) 아이폰 15, 갤럭시 S23
11-
12-
어디가 고장났는지: 예) 액정/배터리/카메라/후면/충전/침수
13-
14-
보험/케어 가입 여부: 예) 애플케어 O/X, 통신사 보험 O/X
15-
16-
가능하면 원하는 방향도 한 줄로 적어주세요: "정품 우선" / "최대한 저렴하게" / "빨리"
17-
18-
예시) “아이폰 15, 액정 깨짐, 애플케어 X, 최대한 저렴하게”
19-
20-
`;
6+
import { useChatMessages } from '../model/useChatMessages';
217

228
const ChatbotPage = () => {
23-
const [message, setMessage] = useState('');
24-
const [messages, setMessages] = useState<ChatMessage[]>(() => [
25-
{
26-
id: 'bot-initial',
27-
role: 'bot' as const,
28-
content: INITIAL_BOT_MESSAGE,
29-
status: 'done',
30-
},
31-
]);
32-
const replyTimeoutRef = useRef<number | null>(null);
9+
const { displayMessages, isHistoryLoading, errorMessage, handleSend } = useChatMessages();
3310
const endOfMessagesRef = useRef<HTMLDivElement | null>(null);
3411

35-
useEffect(() => {
36-
return () => {
37-
if (replyTimeoutRef.current) {
38-
window.clearTimeout(replyTimeoutRef.current);
39-
}
40-
};
41-
}, []);
42-
4312
useEffect(() => {
4413
endOfMessagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
45-
}, [messages]);
46-
47-
const handleSend = (value: string) => {
48-
const trimmed = value.trim();
49-
if (!trimmed) {
50-
return;
51-
}
52-
53-
const userId = `user-${Date.now()}`;
54-
const botId = `bot-${Date.now()}`;
55-
setMessages((prev) => [
56-
...prev,
57-
{ id: userId, role: 'user', content: trimmed, status: 'done' },
58-
{ id: botId, role: 'bot', content: '답변을 준비 중이에요...', status: 'loading' },
59-
]);
60-
setMessage('');
14+
}, [displayMessages.length]);
6115

62-
if (replyTimeoutRef.current) {
63-
window.clearTimeout(replyTimeoutRef.current);
64-
}
65-
replyTimeoutRef.current = window.setTimeout(() => {
66-
setMessages((prev) =>
67-
prev.map((item) =>
68-
item.id === botId
69-
? {
70-
...item,
71-
status: 'done',
72-
content: '입력하신 내용으로 예상 수리비를 준비할게요.\n필요하면 기종/증상 상세를 더 알려주세요!',
73-
}
74-
: item
75-
)
76-
);
77-
}, 800);
78-
};
16+
if (isHistoryLoading) {
17+
return <LoadingFallback message="대화를 불러오는 중" />;
18+
}
7919

8020
return (
8121
<div className="w-full bg-white">
@@ -88,17 +28,13 @@ const ChatbotPage = () => {
8828
</div>
8929
<h1 className="typo-title-2 text-black">루핏봇</h1>
9030
</div>
91-
<ChatMessageList messages={messages} />
31+
<ChatMessageList messages={displayMessages} />
32+
{errorMessage && <div className="w-full text-center text-red-500">{errorMessage}</div>}
9233
<div ref={endOfMessagesRef} className="scroll-mb-[96px]" />
9334
</section>
9435
<div className="md:px-xxxl fixed inset-x-0 bottom-0 z-50 bg-white px-(--margin-l) pt-4 pb-(--margin-s) xl:px-[120px]">
9536
<div className="mx-auto w-full max-w-full xl:max-w-[1200px]">
96-
<ChatInput
97-
placeholder="무슨 견적을 원하시나요?"
98-
value={message}
99-
onChange={setMessage}
100-
onSend={handleSend}
101-
/>
37+
<ChatInput placeholder="무슨 견적을 원하시나요?" onSend={handleSend} />
10238
</div>
10339
</div>
10440
</main>

src/pages/login/ui/KakaoLogin.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1+
import { LoadingFallback } from '@shared/ui/LoadingFallback';
12
import { useKakaoLoginCallback } from '../model/useKakaoLoginCallback';
23

34
export const KakaoLogin = () => {
45
const { errorMessage } = useKakaoLoginCallback();
56

6-
return (
7-
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-4 bg-white px-6">
8-
{errorMessage ? (
7+
if (errorMessage) {
8+
return (
9+
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-4 bg-white px-6">
910
<p className="typo-body-1 text-red-500">{errorMessage}</p>
10-
) : (
11-
<>
12-
<div className="border-brand-primary h-10 w-10 animate-spin rounded-full border-4 border-t-transparent" />
13-
<p className="typo-body-1 text-gray-600">로그인 처리 중</p>
14-
</>
15-
)}
16-
</div>
17-
);
11+
</div>
12+
);
13+
}
14+
15+
return <LoadingFallback message="로그인 처리 중" />;
1816
};

src/pages/main/ui/MainPage.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
import { ROUTES } from '@shared/constants';
2+
import { useAuthStore } from '@shared/stores';
23
import { BannerCard } from '@shared/ui/BannerCard';
34
import { Carousel3D } from '@shared/ui/Carousel3D';
45
import { ChatbotFloatingButton } from '@shared/ui/ChatbotFloatingButton';
56
import { ClientOnly } from '@shared/ui/ClientOnly';
7+
import { Modal } from '@shared/ui/Modal';
8+
import { useState } from 'react';
69
import { useNavigate } from 'react-router';
710
import { BANNER_CARDS } from './BannerCards';
811
import { CAROUSEL_SLIDES } from './CarouselSlides';
912

1013
const MainPage = () => {
1114
const navigate = useNavigate();
15+
const { accessToken } = useAuthStore();
16+
const [showLoginModal, setShowLoginModal] = useState(false);
17+
18+
const handleChatbotClick = () => {
19+
if (!accessToken) {
20+
setShowLoginModal(true);
21+
return;
22+
}
23+
navigate(ROUTES.CHATBOT, { viewTransition: true });
24+
};
1225

1326
return (
14-
<main className="md:px-xxxl flex w-full flex-col items-center gap-6 px-(--margin-l) pb-24 md:gap-10 lg:gap-[68px] lg:px-0">
27+
<main className="md:px-xxxl flex w-full flex-col items-center gap-6 px-(--margin-l) md:gap-10 lg:gap-[68px] lg:px-0">
1528
<section className="h-[317px] w-full max-w-[1200px]" aria-label="메인 슬로건 영역">
1629
<ClientOnly>
1730
<Carousel3D slides={CAROUSEL_SLIDES} />
@@ -31,9 +44,23 @@ const MainPage = () => {
3144
</section>
3245

3346
<ChatbotFloatingButton
34-
className="fixed right-4 bottom-4"
35-
onClick={() => navigate(ROUTES.CHATBOT, { viewTransition: true })}
47+
className="fixed right-[calc(1rem+var(--scrollbar-width))] bottom-4"
48+
onClick={handleChatbotClick}
3649
/>
50+
51+
{showLoginModal && (
52+
<Modal
53+
title="로그인이 필요합니다"
54+
subtitle="로그인 페이지로 이동하시겠습니까?"
55+
cancelText="취소"
56+
confirmText="로그인"
57+
onCancel={() => setShowLoginModal(false)}
58+
onConfirm={() => {
59+
setShowLoginModal(false);
60+
navigate(ROUTES.LOGIN, { viewTransition: true });
61+
}}
62+
/>
63+
)}
3764
</main>
3865
);
3966
};

0 commit comments

Comments
 (0)