Skip to content
42 changes: 26 additions & 16 deletions src/domains/recommend/components/ChatList.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,63 @@
import { useChatScroll } from '../hook/useChatScroll';
import { ChatListProps } from '../types/recommend';
import { ChatListProps, RecommendationItem, StepRecommendation } from '../types/recommend';
import BotMessage from './bot/BotMessage';
import NewMessageAlert from './bot/NewMessageAlert';
import TypingIndicator from './bot/TypingIndicator';
import UserMessage from './user/UserMessage';

function ChatList({
messages,
userCurrentStep,
onSelectedOption,
getRecommendations,
isBotTyping,
}: ChatListProps) {
function ChatList({ messages, userCurrentStep, onSelectedOption }: ChatListProps) {
const { chatListRef, chatEndRef, showNewMessageAlert, handleCheckBottom, handleScrollToBottom } =
useChatScroll(messages[messages.length - 1]?.id);

const getRecommendations = (
type: string | undefined,
stepData?: StepRecommendation | null
): RecommendationItem[] => {
if (type !== 'CARD_LIST' || !stepData?.recommendations) return [];
return stepData.recommendations;
};

return (
<div
ref={chatListRef}
onScroll={handleCheckBottom}
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"
>
<div className="max-w-1024 w-full">
{messages.map((msg) => {
<div className="max-w-1024 w-full flex flex-col gap-5">
{messages.map((msg, i) => {
const keyId =
!msg.id || msg.id === 'null' ? `temp-${msg.sender}-${i}-${Math.random()}` : msg.id;
const prevMsg = messages[i - 1];
const showProfile = !prevMsg || prevMsg.sender !== msg.sender;

if (msg.sender === 'USER') {
return <UserMessage key={`${msg.id}-${msg.sender}`} message={msg.message} />;
return <UserMessage key={keyId} message={msg.message} showProfile={showProfile} />;
}

const isTyping = msg.type === 'TYPING';

const recommendations = getRecommendations(msg.type, msg.stepData);

return (
<BotMessage
key={`${msg.id}-${msg.sender}`}
key={keyId}
messages={[
{
id: msg.id,
message: msg.message,
type: msg.type ?? 'TEXT',
options: msg.type === 'RADIO_OPTIONS' ? (msg.stepData?.options ?? []) : [],
recommendations: getRecommendations(msg.type, msg.stepData),
recommendations,
},
]}
showProfile={showProfile}
stepData={msg.stepData}
currentStep={userCurrentStep}
onSelectedOption={onSelectedOption}
isTyping={isTyping}
/>
);
})}

{isBotTyping && <TypingIndicator />}

<div ref={chatEndRef}></div>
{showNewMessageAlert && <NewMessageAlert onClick={handleScrollToBottom} />}
</div>
Expand Down
123 changes: 48 additions & 75 deletions src/domains/recommend/components/ChatSection.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,49 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import MessageInput from './user/MessageInput';
import {
fetchChatHistory,
fetchGreeting,
fetchSendStepMessage,
fetchSendTextMessage,
} from '../api/chat';
import { fetchSendStepMessage, fetchSendTextMessage } from '../api/chat';
import { useAuthStore } from '@/domains/shared/store/auth';
import {
ChatMessage,
stepPayload,
StepRecommendation,
RecommendationItem,
} from '../types/recommend';
import { ChatMessage, stepPayload } from '../types/recommend';
import ChatList from './ChatList';
import { useChatInit } from '../hook/useChatInit';
import { useSelectedOptions } from '../hook/useSelectedOptions';

function ChatSection() {
const [messages, setMessages] = useState<ChatMessage[]>([]);

const [userCurrentStep, setUserCurrentStep] = useState(0);
const [isBotTyping, setIsBotTyping] = useState(false);
const { selectedOptions, setOption, setStepOption } = useSelectedOptions();

const selectedOptions = useRef<{
selectedAlcoholStrength?: string;
selectedAlcoholBaseType?: string;
selectedCocktailType?: string;
}>({});
const isInputDisabled =
selectedOptions.current.selectedSearchType !== 'QA' && userCurrentStep < 3;

const handleSendMessage = async (payload: stepPayload | { message: string; userId: string }) => {
const typingTimer = setTimeout(() => setIsBotTyping(true), 300);

try {
if (!('currentStep' in payload)) {
const botMessage = await fetchSendTextMessage(payload);
clearTimeout(typingTimer);
setIsBotTyping(false);
const tempTypingId = `typing-${Date.now()}`;

if (!botMessage) return;
setTimeout(() => setMessages((prev) => [...prev, botMessage]), 500);
return;
}
// Typing 메시지 임시 추가
setMessages((prev) => [
...prev,
{
id: tempTypingId,
sender: 'CHATBOT',
type: 'TYPING',
message: '',
createdAt: new Date().toISOString(),
},
]);

const botMessage = await fetchSendStepMessage(payload);
clearTimeout(typingTimer);
setIsBotTyping(false);
try {
const botMessage =
'currentStep' in payload
? await fetchSendStepMessage(payload)
: await fetchSendTextMessage(payload);

if (!botMessage) return;
setTimeout(() => setMessages((prev) => [...prev, botMessage]), 500);

setMessages((prev) => prev.map((msg) => (msg.id === tempTypingId ? botMessage : msg)));
} catch (err) {
clearTimeout(typingTimer);
setIsBotTyping(false);
console.error(err);
setMessages((prev) => prev.filter((msg) => msg.id !== tempTypingId));
}
};

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

await handleSendMessage({ message, userId });
const nextStep = userCurrentStep === 3 ? userCurrentStep + 1 : userCurrentStep;

const payload: stepPayload =
nextStep === 0
? { message, userId, currentStep: nextStep }
: { message, userId, currentStep: nextStep, ...selectedOptions.current };

await handleSendMessage(payload);
};

// 옵션 클릭 시
Expand Down Expand Up @@ -103,49 +101,26 @@ function ChatSection() {
},
]);

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

switch (stepData.currentStep + 1) {
case 2:
selectedOptions.current.selectedAlcoholStrength = value;
break;
case 3:
selectedOptions.current.selectedAlcoholBaseType = value;
break;
// 0단계에서 QA 선택 시
if (stepData.currentStep === 0 && value === 'QA') {
setOption('selectedSearchType', 'QA');
}

const payload: stepPayload = {
message: selectedLabel,
userId,
currentStep: nextStep,
...selectedOptions.current,
};
setStepOption(stepData.currentStep + 1, value);

const payload: stepPayload =
nextStep === 0
? { message: selectedLabel, userId, currentStep: nextStep }
: { message: selectedLabel, userId, currentStep: nextStep, ...selectedOptions.current };

await handleSendMessage(payload);
};

// 채팅 기록 불러오기 없으면 greeting 호출
useEffect(() => {
const loadChatHistory = async () => {
const history = await fetchChatHistory();
if (history && history.length > 0) {
setMessages(history.sort((a, b) => Number(a.id) - Number(b.id)));
} else {
const greeting = await fetchGreeting('');
if (greeting) setMessages([greeting]);
}
};
loadChatHistory();
}, []);

const getRecommendations = (
type: string | undefined,
stepData?: StepRecommendation | null
): RecommendationItem[] => {
if (type !== 'CARD_LIST' || !stepData?.recommendations) return [];
return stepData.recommendations;
};
useChatInit(setMessages);

return (
<section className="relative flex-1 flex flex-col items-center w-full">
Expand All @@ -154,10 +129,8 @@ function ChatSection() {
messages={messages}
userCurrentStep={userCurrentStep}
onSelectedOption={handleSelectedOption}
getRecommendations={getRecommendations}
isBotTyping={isBotTyping}
/>
<MessageInput onSubmit={handleSubmitText} />
<MessageInput onSubmit={handleSubmitText} disabled={isInputDisabled} />
</section>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/domains/recommend/components/bot/BotCocktailCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function BotCocktailCard({
<span className="text-gray-500 text-sm">+ 상세보기</span>
</div>
</Link>
<Keep className="absolute top-2 right-2" />
<Keep cocktailId={cocktailId} className="absolute top-2 right-2" />
</div>
);
}
Expand Down
82 changes: 51 additions & 31 deletions src/domains/recommend/components/bot/BotMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useState } from 'react';
import BotCocktailCard from './BotCocktailCard';
import BotOptions from './BotOptions';
import { StepOption, StepRecommendation, RecommendationItem } from '../../types/recommend';
import TypingIndicator from './TypingIndicator';

interface BotMessage {
id: string;
Expand All @@ -17,35 +18,70 @@ interface BotMessage {

interface BotMessages {
messages: BotMessage[];
showProfile: boolean;
stepData?: StepRecommendation | null;
currentStep?: number;
onSelectedOption?: (value: string) => void;
isTyping?: boolean;
}

function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMessages) {
function BotMessage({
messages,
showProfile,
stepData,
currentStep,
onSelectedOption,
isTyping,
}: BotMessages) {
const [selected, setSelected] = useState('');

return (
<article aria-label="취향추천 챗봇 메시지" className="">
<header className="flex items-end">
<div className="relative w-15 md:w-20 h-15 md:h-20">
<Image
src={Ssury}
alt="쑤리아바타"
width={80}
height={80}
className="object-cover w-15 h-15 md:w-20 md:h-20"
/>
</div>
<strong>쑤리</strong>
</header>
{showProfile && (
<header className="flex items-end">
<div className="relative w-15 md:w-20 h-15 md:h-20">
<Image
src={Ssury}
alt="쑤리아바타"
width={80}
height={80}
className="object-cover w-15 h-15 md:w-20 md:h-20"
/>
</div>
<strong>쑤리</strong>
</header>
)}

{/* 메시지 그룹 */}
<div className="flex flex-col gap-3 mt-3 pl-3">
{messages.map((msg) => (
<div key={msg.id}>
<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">
<div>
{isTyping ? (
<TypingIndicator />
) : (
<p className="whitespace-pre-line">{msg.message}</p>
)}
</div>

{/* radio */}
{msg.type === 'RADIO_OPTIONS' && msg.options?.length && (
<BotOptions
options={msg.options}
step={stepData?.currentStep ?? 0}
currentStep={currentStep ?? 0}
value={selected}
onChange={(val) => {
setSelected(val);
onSelectedOption?.(val);
}}
/>
)}
{/* {children} */}
</div>
{msg.type === 'CARD_LIST' && msg.recommendations?.length ? (
<ul className="inline-grid grid-cols-1 sm:grid-cols-3 gap-2 justify-start">
<ul className="inline-grid grid-cols-1 mt-5 sm:grid-cols-3 gap-2 justify-start">
{msg.recommendations.map((rec) => (
<li key={rec.cocktailId}>
<BotCocktailCard
Expand All @@ -59,23 +95,7 @@ function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMe
))}
</ul>
) : (
<div className="flex flex-col w-fit max-w-[80%] min-w-[120px] p-3 rounded-2xl rounded-tl-none bg-white text-black">
{msg.message && <p className="whitespace-pre-line">{msg.message}</p>}

{/* radio */}
{msg.type === 'RADIO_OPTIONS' && msg.options?.length && (
<BotOptions
options={msg.options}
step={stepData?.currentStep ?? 0}
currentStep={currentStep ?? 0}
value={selected}
onChange={(val) => {
setSelected(val);
onSelectedOption?.(val);
}}
/>
)}
</div>
''
)}
</div>
))}
Expand Down
2 changes: 1 addition & 1 deletion src/domains/recommend/components/bot/TypingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import shaker from '@/shared/assets/images/shaker.png';

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