diff --git a/src/domains/recommend/components/ChatList.tsx b/src/domains/recommend/components/ChatList.tsx index ae2286a..2178539 100644 --- a/src/domains/recommend/components/ChatList.tsx +++ b/src/domains/recommend/components/ChatList.tsx @@ -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 (
-
- {messages.map((msg) => { +
+ {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 ; + return ; } + const isTyping = msg.type === 'TYPING'; + + const recommendations = getRecommendations(msg.type, msg.stepData); + return ( ); })} - {isBotTyping && } -
{showNewMessageAlert && }
diff --git a/src/domains/recommend/components/ChatSection.tsx b/src/domains/recommend/components/ChatSection.tsx index 6aff917..f086d8c 100644 --- a/src/domains/recommend/components/ChatSection.tsx +++ b/src/domains/recommend/components/ChatSection.tsx @@ -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([]); - 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)); } }; @@ -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); }; // 옵션 클릭 시 @@ -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 (
@@ -154,10 +129,8 @@ function ChatSection() { messages={messages} userCurrentStep={userCurrentStep} onSelectedOption={handleSelectedOption} - getRecommendations={getRecommendations} - isBotTyping={isBotTyping} /> - +
); } diff --git a/src/domains/recommend/components/bot/BotCocktailCard.tsx b/src/domains/recommend/components/bot/BotCocktailCard.tsx index c5e6b8f..d865943 100644 --- a/src/domains/recommend/components/bot/BotCocktailCard.tsx +++ b/src/domains/recommend/components/bot/BotCocktailCard.tsx @@ -29,7 +29,7 @@ function BotCocktailCard({ + 상세보기
- +
); } diff --git a/src/domains/recommend/components/bot/BotMessage.tsx b/src/domains/recommend/components/bot/BotMessage.tsx index d74afde..2155259 100644 --- a/src/domains/recommend/components/bot/BotMessage.tsx +++ b/src/domains/recommend/components/bot/BotMessage.tsx @@ -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; @@ -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 (
-
-
- 쑤리아바타 -
- 쑤리 -
+ {showProfile && ( +
+
+ 쑤리아바타 +
+ 쑤리 +
+ )} {/* 메시지 그룹 */}
{messages.map((msg) => (
+
+
+ {isTyping ? ( + + ) : ( +

{msg.message}

+ )} +
+ + {/* radio */} + {msg.type === 'RADIO_OPTIONS' && msg.options?.length && ( + { + setSelected(val); + onSelectedOption?.(val); + }} + /> + )} + {/* {children} */} +
{msg.type === 'CARD_LIST' && msg.recommendations?.length ? ( -
    +
      {msg.recommendations.map((rec) => (
    • ) : ( -
      - {msg.message &&

      {msg.message}

      } - - {/* radio */} - {msg.type === 'RADIO_OPTIONS' && msg.options?.length && ( - { - setSelected(val); - onSelectedOption?.(val); - }} - /> - )} -
      + '' )}
))} diff --git a/src/domains/recommend/components/bot/TypingIndicator.tsx b/src/domains/recommend/components/bot/TypingIndicator.tsx index 0463849..f49bdbb 100644 --- a/src/domains/recommend/components/bot/TypingIndicator.tsx +++ b/src/domains/recommend/components/bot/TypingIndicator.tsx @@ -3,7 +3,7 @@ import shaker from '@/shared/assets/images/shaker.png'; function TypingIndicator() { return ( -
+

준비 중…

void; + disabled: boolean; } -function MessageInput({ onSubmit }: Props) { +function MessageInput({ onSubmit, disabled }: Props) { const [value, setValue] = useState(''); const textareaRef = useRef(null); @@ -35,8 +36,14 @@ function MessageInput({ onSubmit }: Props) { id="chatInput" name="chatInput" onInput={(e) => resizeTextarea(e.currentTarget)} - placeholder="칵테일 추천 질문을 입력해주세요." - className="w-[calc(100%-3rem)] md:w-[calc(100%-3.75rem)] px-4 py-2 md:py-3.5 rounded-lg h-[40px] md:h-[52px] max-h-[160px] md:max-h-[280px] bg-white text-primary placeholder:text-gray-dark resize-none outline-none" + placeholder={disabled ? '옵션을 선택해주세요.' : '칵테일 추천 질문을 입력해주세요.'} + disabled={disabled} + className={` + w-[calc(100%-3rem)] md:w-[calc(100%-3.75rem)] px-4 py-2 md:py-3.5 + rounded-lg h-[40px] md:h-[52px] max-h-[160px] md:max-h-[280px] + bg-white text-primary placeholder:text-gray-dark resize-none outline-none + disabled:bg-gray disabled:text-gray-dark disabled:cursor-not-allowed + `} />