Skip to content

Commit e282d9b

Browse files
authored
[feat] 챗봇 추천페이지 로직 수정 (#107)
* [fix] 로딩중일때도 스크롤되도록 수정 * [fix] 말풍선안에서 로딩 -> 원래 data 변화되도록 수정 * [fix] 로딩 수정, botmessage 로직 수정 * [feat] input disabled 로직 추가 * [fix] input disabled 로직 수정 * [fix] 오류 시에도 중복 키값 안받게 * [refactor] 채팅기록 불러오기 hook 분리 * [refactor] selectedOptions hook 분리 * [feat] keep 저장 연결
1 parent a613279 commit e282d9b

File tree

12 files changed

+221
-145
lines changed

12 files changed

+221
-145
lines changed

src/domains/recommend/components/ChatList.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,63 @@
11
import { useChatScroll } from '../hook/useChatScroll';
2-
import { ChatListProps } from '../types/recommend';
2+
import { ChatListProps, RecommendationItem, StepRecommendation } from '../types/recommend';
33
import BotMessage from './bot/BotMessage';
44
import NewMessageAlert from './bot/NewMessageAlert';
5-
import TypingIndicator from './bot/TypingIndicator';
65
import UserMessage from './user/UserMessage';
76

8-
function ChatList({
9-
messages,
10-
userCurrentStep,
11-
onSelectedOption,
12-
getRecommendations,
13-
isBotTyping,
14-
}: ChatListProps) {
7+
function ChatList({ messages, userCurrentStep, onSelectedOption }: ChatListProps) {
158
const { chatListRef, chatEndRef, showNewMessageAlert, handleCheckBottom, handleScrollToBottom } =
169
useChatScroll(messages[messages.length - 1]?.id);
1710

11+
const getRecommendations = (
12+
type: string | undefined,
13+
stepData?: StepRecommendation | null
14+
): RecommendationItem[] => {
15+
if (type !== 'CARD_LIST' || !stepData?.recommendations) return [];
16+
return stepData.recommendations;
17+
};
18+
1819
return (
1920
<div
2021
ref={chatListRef}
2122
onScroll={handleCheckBottom}
2223
className="absolute top-0 left-0 bottom-18 sm:bottom-21 w-full gap-5 px-3 pt-12 pb-4 flex flex-col items-center overflow-y-auto pr-2"
2324
>
24-
<div className="max-w-1024 w-full">
25-
{messages.map((msg) => {
25+
<div className="max-w-1024 w-full flex flex-col gap-5">
26+
{messages.map((msg, i) => {
27+
const keyId =
28+
!msg.id || msg.id === 'null' ? `temp-${msg.sender}-${i}-${Math.random()}` : msg.id;
29+
const prevMsg = messages[i - 1];
30+
const showProfile = !prevMsg || prevMsg.sender !== msg.sender;
31+
2632
if (msg.sender === 'USER') {
27-
return <UserMessage key={`${msg.id}-${msg.sender}`} message={msg.message} />;
33+
return <UserMessage key={keyId} message={msg.message} showProfile={showProfile} />;
2834
}
2935

36+
const isTyping = msg.type === 'TYPING';
37+
38+
const recommendations = getRecommendations(msg.type, msg.stepData);
39+
3040
return (
3141
<BotMessage
32-
key={`${msg.id}-${msg.sender}`}
42+
key={keyId}
3343
messages={[
3444
{
3545
id: msg.id,
3646
message: msg.message,
3747
type: msg.type ?? 'TEXT',
3848
options: msg.type === 'RADIO_OPTIONS' ? (msg.stepData?.options ?? []) : [],
39-
recommendations: getRecommendations(msg.type, msg.stepData),
49+
recommendations,
4050
},
4151
]}
52+
showProfile={showProfile}
4253
stepData={msg.stepData}
4354
currentStep={userCurrentStep}
4455
onSelectedOption={onSelectedOption}
56+
isTyping={isTyping}
4557
/>
4658
);
4759
})}
4860

49-
{isBotTyping && <TypingIndicator />}
50-
5161
<div ref={chatEndRef}></div>
5262
{showNewMessageAlert && <NewMessageAlert onClick={handleScrollToBottom} />}
5363
</div>

src/domains/recommend/components/ChatSection.tsx

Lines changed: 48 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,49 @@
11
'use client';
22

3-
import { useEffect, useRef, useState } from 'react';
3+
import { useState } from 'react';
44
import MessageInput from './user/MessageInput';
5-
import {
6-
fetchChatHistory,
7-
fetchGreeting,
8-
fetchSendStepMessage,
9-
fetchSendTextMessage,
10-
} from '../api/chat';
5+
import { fetchSendStepMessage, fetchSendTextMessage } from '../api/chat';
116
import { useAuthStore } from '@/domains/shared/store/auth';
12-
import {
13-
ChatMessage,
14-
stepPayload,
15-
StepRecommendation,
16-
RecommendationItem,
17-
} from '../types/recommend';
7+
import { ChatMessage, stepPayload } from '../types/recommend';
188
import ChatList from './ChatList';
9+
import { useChatInit } from '../hook/useChatInit';
10+
import { useSelectedOptions } from '../hook/useSelectedOptions';
1911

2012
function ChatSection() {
2113
const [messages, setMessages] = useState<ChatMessage[]>([]);
22-
2314
const [userCurrentStep, setUserCurrentStep] = useState(0);
24-
const [isBotTyping, setIsBotTyping] = useState(false);
15+
const { selectedOptions, setOption, setStepOption } = useSelectedOptions();
2516

26-
const selectedOptions = useRef<{
27-
selectedAlcoholStrength?: string;
28-
selectedAlcoholBaseType?: string;
29-
selectedCocktailType?: string;
30-
}>({});
17+
const isInputDisabled =
18+
selectedOptions.current.selectedSearchType !== 'QA' && userCurrentStep < 3;
3119

3220
const handleSendMessage = async (payload: stepPayload | { message: string; userId: string }) => {
33-
const typingTimer = setTimeout(() => setIsBotTyping(true), 300);
34-
35-
try {
36-
if (!('currentStep' in payload)) {
37-
const botMessage = await fetchSendTextMessage(payload);
38-
clearTimeout(typingTimer);
39-
setIsBotTyping(false);
21+
const tempTypingId = `typing-${Date.now()}`;
4022

41-
if (!botMessage) return;
42-
setTimeout(() => setMessages((prev) => [...prev, botMessage]), 500);
43-
return;
44-
}
23+
// Typing 메시지 임시 추가
24+
setMessages((prev) => [
25+
...prev,
26+
{
27+
id: tempTypingId,
28+
sender: 'CHATBOT',
29+
type: 'TYPING',
30+
message: '',
31+
createdAt: new Date().toISOString(),
32+
},
33+
]);
4534

46-
const botMessage = await fetchSendStepMessage(payload);
47-
clearTimeout(typingTimer);
48-
setIsBotTyping(false);
35+
try {
36+
const botMessage =
37+
'currentStep' in payload
38+
? await fetchSendStepMessage(payload)
39+
: await fetchSendTextMessage(payload);
4940

5041
if (!botMessage) return;
51-
setTimeout(() => setMessages((prev) => [...prev, botMessage]), 500);
42+
43+
setMessages((prev) => prev.map((msg) => (msg.id === tempTypingId ? botMessage : msg)));
5244
} catch (err) {
53-
clearTimeout(typingTimer);
54-
setIsBotTyping(false);
5545
console.error(err);
46+
setMessages((prev) => prev.filter((msg) => msg.id !== tempTypingId));
5647
}
5748
};
5849

@@ -70,7 +61,14 @@ function ChatSection() {
7061
{ id: tempId, userId, message, sender: 'USER', type: 'text', createdAt: tempCreatedAt },
7162
]);
7263

73-
await handleSendMessage({ message, userId });
64+
const nextStep = userCurrentStep === 3 ? userCurrentStep + 1 : userCurrentStep;
65+
66+
const payload: stepPayload =
67+
nextStep === 0
68+
? { message, userId, currentStep: nextStep }
69+
: { message, userId, currentStep: nextStep, ...selectedOptions.current };
70+
71+
await handleSendMessage(payload);
7472
};
7573

7674
// 옵션 클릭 시
@@ -103,49 +101,26 @@ function ChatSection() {
103101
},
104102
]);
105103

104+
// QA (질문형) 일 시 0 나머지는 +1
106105
const nextStep = value === 'QA' ? 0 : (stepData?.currentStep ?? 0) + 1;
107106
setUserCurrentStep(nextStep);
108107

109-
switch (stepData.currentStep + 1) {
110-
case 2:
111-
selectedOptions.current.selectedAlcoholStrength = value;
112-
break;
113-
case 3:
114-
selectedOptions.current.selectedAlcoholBaseType = value;
115-
break;
108+
// 0단계에서 QA 선택 시
109+
if (stepData.currentStep === 0 && value === 'QA') {
110+
setOption('selectedSearchType', 'QA');
116111
}
117112

118-
const payload: stepPayload = {
119-
message: selectedLabel,
120-
userId,
121-
currentStep: nextStep,
122-
...selectedOptions.current,
123-
};
113+
setStepOption(stepData.currentStep + 1, value);
114+
115+
const payload: stepPayload =
116+
nextStep === 0
117+
? { message: selectedLabel, userId, currentStep: nextStep }
118+
: { message: selectedLabel, userId, currentStep: nextStep, ...selectedOptions.current };
124119

125120
await handleSendMessage(payload);
126121
};
127122

128-
// 채팅 기록 불러오기 없으면 greeting 호출
129-
useEffect(() => {
130-
const loadChatHistory = async () => {
131-
const history = await fetchChatHistory();
132-
if (history && history.length > 0) {
133-
setMessages(history.sort((a, b) => Number(a.id) - Number(b.id)));
134-
} else {
135-
const greeting = await fetchGreeting('');
136-
if (greeting) setMessages([greeting]);
137-
}
138-
};
139-
loadChatHistory();
140-
}, []);
141-
142-
const getRecommendations = (
143-
type: string | undefined,
144-
stepData?: StepRecommendation | null
145-
): RecommendationItem[] => {
146-
if (type !== 'CARD_LIST' || !stepData?.recommendations) return [];
147-
return stepData.recommendations;
148-
};
123+
useChatInit(setMessages);
149124

150125
return (
151126
<section className="relative flex-1 flex flex-col items-center w-full">
@@ -154,10 +129,8 @@ function ChatSection() {
154129
messages={messages}
155130
userCurrentStep={userCurrentStep}
156131
onSelectedOption={handleSelectedOption}
157-
getRecommendations={getRecommendations}
158-
isBotTyping={isBotTyping}
159132
/>
160-
<MessageInput onSubmit={handleSubmitText} />
133+
<MessageInput onSubmit={handleSubmitText} disabled={isInputDisabled} />
161134
</section>
162135
);
163136
}

src/domains/recommend/components/bot/BotCocktailCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function BotCocktailCard({
2929
<span className="text-gray-500 text-sm">+ 상세보기</span>
3030
</div>
3131
</Link>
32-
<Keep className="absolute top-2 right-2" />
32+
<Keep cocktailId={cocktailId} className="absolute top-2 right-2" />
3333
</div>
3434
);
3535
}

src/domains/recommend/components/bot/BotMessage.tsx

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useState } from 'react';
66
import BotCocktailCard from './BotCocktailCard';
77
import BotOptions from './BotOptions';
88
import { StepOption, StepRecommendation, RecommendationItem } from '../../types/recommend';
9+
import TypingIndicator from './TypingIndicator';
910

1011
interface BotMessage {
1112
id: string;
@@ -17,35 +18,70 @@ interface BotMessage {
1718

1819
interface BotMessages {
1920
messages: BotMessage[];
21+
showProfile: boolean;
2022
stepData?: StepRecommendation | null;
2123
currentStep?: number;
2224
onSelectedOption?: (value: string) => void;
25+
isTyping?: boolean;
2326
}
2427

25-
function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMessages) {
28+
function BotMessage({
29+
messages,
30+
showProfile,
31+
stepData,
32+
currentStep,
33+
onSelectedOption,
34+
isTyping,
35+
}: BotMessages) {
2636
const [selected, setSelected] = useState('');
2737

2838
return (
2939
<article aria-label="취향추천 챗봇 메시지" className="">
30-
<header className="flex items-end">
31-
<div className="relative w-15 md:w-20 h-15 md:h-20">
32-
<Image
33-
src={Ssury}
34-
alt="쑤리아바타"
35-
width={80}
36-
height={80}
37-
className="object-cover w-15 h-15 md:w-20 md:h-20"
38-
/>
39-
</div>
40-
<strong>쑤리</strong>
41-
</header>
40+
{showProfile && (
41+
<header className="flex items-end">
42+
<div className="relative w-15 md:w-20 h-15 md:h-20">
43+
<Image
44+
src={Ssury}
45+
alt="쑤리아바타"
46+
width={80}
47+
height={80}
48+
className="object-cover w-15 h-15 md:w-20 md:h-20"
49+
/>
50+
</div>
51+
<strong>쑤리</strong>
52+
</header>
53+
)}
4254

4355
{/* 메시지 그룹 */}
4456
<div className="flex flex-col gap-3 mt-3 pl-3">
4557
{messages.map((msg) => (
4658
<div key={msg.id}>
59+
<div className="flex flex-col w-fit max-w-[80%] min-w-[120px] p-3 rounded-2xl rounded-tl-none bg-white text-black opacity-0 animate-fadeIn">
60+
<div>
61+
{isTyping ? (
62+
<TypingIndicator />
63+
) : (
64+
<p className="whitespace-pre-line">{msg.message}</p>
65+
)}
66+
</div>
67+
68+
{/* radio */}
69+
{msg.type === 'RADIO_OPTIONS' && msg.options?.length && (
70+
<BotOptions
71+
options={msg.options}
72+
step={stepData?.currentStep ?? 0}
73+
currentStep={currentStep ?? 0}
74+
value={selected}
75+
onChange={(val) => {
76+
setSelected(val);
77+
onSelectedOption?.(val);
78+
}}
79+
/>
80+
)}
81+
{/* {children} */}
82+
</div>
4783
{msg.type === 'CARD_LIST' && msg.recommendations?.length ? (
48-
<ul className="inline-grid grid-cols-1 sm:grid-cols-3 gap-2 justify-start">
84+
<ul className="inline-grid grid-cols-1 mt-5 sm:grid-cols-3 gap-2 justify-start">
4985
{msg.recommendations.map((rec) => (
5086
<li key={rec.cocktailId}>
5187
<BotCocktailCard
@@ -59,23 +95,7 @@ function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMe
5995
))}
6096
</ul>
6197
) : (
62-
<div className="flex flex-col w-fit max-w-[80%] min-w-[120px] p-3 rounded-2xl rounded-tl-none bg-white text-black">
63-
{msg.message && <p className="whitespace-pre-line">{msg.message}</p>}
64-
65-
{/* radio */}
66-
{msg.type === 'RADIO_OPTIONS' && msg.options?.length && (
67-
<BotOptions
68-
options={msg.options}
69-
step={stepData?.currentStep ?? 0}
70-
currentStep={currentStep ?? 0}
71-
value={selected}
72-
onChange={(val) => {
73-
setSelected(val);
74-
onSelectedOption?.(val);
75-
}}
76-
/>
77-
)}
78-
</div>
98+
''
7999
)}
80100
</div>
81101
))}

src/domains/recommend/components/bot/TypingIndicator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import shaker from '@/shared/assets/images/shaker.png';
33

44
function TypingIndicator() {
55
return (
6-
<div className="relative flex items-center w-fit ml-3 p-3 rounded-2xl rounded-tl-none bg-white text-black overflow-hidden">
6+
<div className="relative flex items-center w-fittext-black">
77
<p className="inline-block animate-fade-in">준비 중…</p>
88
<div className="relative w-10 h-10 animate-shake">
99
<Image

0 commit comments

Comments
 (0)