From e4402b04ca82748043ab21f5adc0a561d774653b Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Sat, 4 Oct 2025 16:06:42 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[fix]=20=EB=A1=9C=EB=94=A9=EC=A4=91?= =?UTF-8?q?=EC=9D=BC=EB=95=8C=EB=8F=84=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/recommend/hook/useChatScroll.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/domains/recommend/hook/useChatScroll.ts b/src/domains/recommend/hook/useChatScroll.ts index 96a093e..b15e1f8 100644 --- a/src/domains/recommend/hook/useChatScroll.ts +++ b/src/domains/recommend/hook/useChatScroll.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -export const useChatScroll = (lastMessageId: string) => { +export const useChatScroll = (lastMessageId: string, isBotTyping: boolean) => { const chatEndRef = useRef(null); const chatListRef = useRef(null); const isScrollBottom = useRef(true); @@ -17,18 +17,16 @@ export const useChatScroll = (lastMessageId: string) => { // 새 메시지가 들어오면 자동 스크롤 useEffect(() => { - if (!isScrollBottom.current) { - setShowNewMessageAlert(true); - return; - } - const frameId = requestAnimationFrame(() => { - chatEndRef.current?.scrollIntoView({ behavior: 'auto' }); - setShowNewMessageAlert(false); + setTimeout(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + isScrollBottom.current = true; + setShowNewMessageAlert(false); + }, 50); // 50ms 정도 살짝 기다림 }); return () => cancelAnimationFrame(frameId); - }, [lastMessageId]); + }, [lastMessageId, isBotTyping]); // 스크롤 제일 아래로 const handleScrollToBottom = () => { From f699df5248c8ebdfbc1ba8f5de081da01957cbc8 Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Sat, 4 Oct 2025 17:02:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[fix]=20=EB=A7=90=ED=92=8D=EC=84=A0?= =?UTF-8?q?=EC=95=88=EC=97=90=EC=84=9C=20=EB=A1=9C=EB=94=A9=20->=20?= =?UTF-8?q?=EC=9B=90=EB=9E=98=20data=20=EB=B3=80=ED=99=94=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/recommend/components/ChatList.tsx | 6 +-- .../recommend/components/ChatSection.tsx | 47 ++++++++++++------- .../recommend/components/bot/BotMessage.tsx | 14 ++++-- .../components/bot/TypingIndicator.tsx | 2 +- src/domains/recommend/hook/useChatScroll.ts | 15 +++--- src/shared/styles/_utilities.css | 14 ++++++ 6 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/domains/recommend/components/ChatList.tsx b/src/domains/recommend/components/ChatList.tsx index ae2286a..4d72c2e 100644 --- a/src/domains/recommend/components/ChatList.tsx +++ b/src/domains/recommend/components/ChatList.tsx @@ -2,7 +2,6 @@ import { useChatScroll } from '../hook/useChatScroll'; import { ChatListProps } 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({ @@ -10,7 +9,6 @@ function ChatList({ userCurrentStep, onSelectedOption, getRecommendations, - isBotTyping, }: ChatListProps) { const { chatListRef, chatEndRef, showNewMessageAlert, handleCheckBottom, handleScrollToBottom } = useChatScroll(messages[messages.length - 1]?.id); @@ -33,7 +31,7 @@ function ChatList({ messages={[ { id: msg.id, - message: msg.message, + message: msg.type === 'TYPING' ? '' : msg.message, type: msg.type ?? 'TEXT', options: msg.type === 'RADIO_OPTIONS' ? (msg.stepData?.options ?? []) : [], recommendations: getRecommendations(msg.type, msg.stepData), @@ -46,8 +44,6 @@ function ChatList({ ); })} - {isBotTyping && } -
{showNewMessageAlert && } diff --git a/src/domains/recommend/components/ChatSection.tsx b/src/domains/recommend/components/ChatSection.tsx index 6aff917..1522ee6 100644 --- a/src/domains/recommend/components/ChatSection.tsx +++ b/src/domains/recommend/components/ChatSection.tsx @@ -30,29 +30,31 @@ function ChatSection() { }>({}); const handleSendMessage = async (payload: stepPayload | { message: string; userId: string }) => { - const typingTimer = setTimeout(() => setIsBotTyping(true), 300); + // Typing 임시메시지 + setMessages((prev) => [ + ...prev, + { + id: 'typing', + sender: 'CHATBOT', + type: 'TYPING', + message: '', + createdAt: new Date().toISOString(), + }, + ]); try { - if (!('currentStep' in payload)) { - const botMessage = await fetchSendTextMessage(payload); - clearTimeout(typingTimer); - setIsBotTyping(false); - - if (!botMessage) return; - setTimeout(() => setMessages((prev) => [...prev, botMessage]), 500); - return; - } - - const botMessage = await fetchSendStepMessage(payload); - clearTimeout(typingTimer); - setIsBotTyping(false); + const botMessage = + 'currentStep' in payload + ? await fetchSendStepMessage(payload) + : await fetchSendTextMessage(payload); if (!botMessage) return; - setTimeout(() => setMessages((prev) => [...prev, botMessage]), 500); + + // Typing 임시메시지 제거 후 실제 메시지 추가 + setMessages((prev) => [...prev.filter((m) => m.type !== 'TYPING'), botMessage]); } catch (err) { - clearTimeout(typingTimer); - setIsBotTyping(false); console.error(err); + setMessages((prev) => prev.filter((m) => m.type !== 'TYPING')); } }; @@ -70,7 +72,16 @@ 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 = { + currentStep: nextStep, + message, + userId, + ...selectedOptions.current, + }; + + await handleSendMessage(payload); }; // 옵션 클릭 시 diff --git a/src/domains/recommend/components/bot/BotMessage.tsx b/src/domains/recommend/components/bot/BotMessage.tsx index d74afde..261ed1d 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; @@ -19,10 +20,11 @@ interface BotMessages { messages: BotMessage[]; stepData?: StepRecommendation | null; currentStep?: number; + children?: React.ReactNode; onSelectedOption?: (value: string) => void; } -function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMessages) { +function BotMessage({ messages, stepData, currentStep, onSelectedOption, children }: BotMessages) { const [selected, setSelected] = useState(''); return ( @@ -59,8 +61,13 @@ function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMe ))} ) : ( -
- {msg.message &&

{msg.message}

} +
+ {msg.type === 'TYPING' ? ( + + ) : ( + // 실제 메시지 내용 +

{msg.message}

+ )} {/* radio */} {msg.type === 'RADIO_OPTIONS' && msg.options?.length && ( @@ -75,6 +82,7 @@ function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMe }} /> )} + {/* {children} */}
)}
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 ( -
+

준비 중…

{ +export const useChatScroll = (lastMessageId: string) => { const chatEndRef = useRef(null); const chatListRef = useRef(null); const isScrollBottom = useRef(true); @@ -9,24 +9,21 @@ export const useChatScroll = (lastMessageId: string, isBotTyping: boolean) => { // 스크롤 제일 아래인지 체크 const handleCheckBottom = (e: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - isScrollBottom.current = scrollTop + clientHeight >= scrollHeight - 10; if (isScrollBottom.current) setShowNewMessageAlert(false); }; - // 새 메시지가 들어오면 자동 스크롤 + // 새 메시지또는 로딩중 변화 시 자동 스크롤 useEffect(() => { const frameId = requestAnimationFrame(() => { - setTimeout(() => { - chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - isScrollBottom.current = true; - setShowNewMessageAlert(false); - }, 50); // 50ms 정도 살짝 기다림 + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + isScrollBottom.current = true; + setShowNewMessageAlert(false); }); return () => cancelAnimationFrame(frameId); - }, [lastMessageId, isBotTyping]); + }, [lastMessageId]); // 스크롤 제일 아래로 const handleScrollToBottom = () => { diff --git a/src/shared/styles/_utilities.css b/src/shared/styles/_utilities.css index 60db4b2..6b5e5a9 100644 --- a/src/shared/styles/_utilities.css +++ b/src/shared/styles/_utilities.css @@ -95,4 +95,18 @@ .animate-shake { animation: shake 3s ease-in-out infinite; } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .animate-fadeIn { + animation: fadeIn 0.3s ease-out forwards; + } } From 5304cdf7edcfd4764e0909285524020aeb06f144 Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Sat, 4 Oct 2025 19:20:48 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[fix]=20=EB=A1=9C=EB=94=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20botmessage=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/recommend/components/ChatList.tsx | 24 +++-- .../recommend/components/ChatSection.tsx | 15 ++-- .../recommend/components/bot/BotMessage.tsx | 88 +++++++++++-------- .../recommend/components/user/UserMessage.tsx | 11 ++- src/domains/recommend/types/recommend.ts | 4 +- 5 files changed, 84 insertions(+), 58 deletions(-) diff --git a/src/domains/recommend/components/ChatList.tsx b/src/domains/recommend/components/ChatList.tsx index 4d72c2e..839872c 100644 --- a/src/domains/recommend/components/ChatList.tsx +++ b/src/domains/recommend/components/ChatList.tsx @@ -19,27 +19,41 @@ function ChatList({ 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" > -
- {messages.map((msg) => { +
+ {messages.map((msg, i) => { + const keyId = msg.id ?? `temp-${msg.sender}-${i}-${Date.now()}`; + const prevMsg = messages[i - 1]; + const showProfile = !prevMsg || prevMsg.sender !== msg.sender; + if (msg.sender === 'USER') { - return ; + return ( + + ); } + const isTyping = msg.type === 'TYPING'; + return ( ); })} diff --git a/src/domains/recommend/components/ChatSection.tsx b/src/domains/recommend/components/ChatSection.tsx index 1522ee6..bc00706 100644 --- a/src/domains/recommend/components/ChatSection.tsx +++ b/src/domains/recommend/components/ChatSection.tsx @@ -19,10 +19,7 @@ import ChatList from './ChatList'; function ChatSection() { const [messages, setMessages] = useState([]); - const [userCurrentStep, setUserCurrentStep] = useState(0); - const [isBotTyping, setIsBotTyping] = useState(false); - const selectedOptions = useRef<{ selectedAlcoholStrength?: string; selectedAlcoholBaseType?: string; @@ -30,11 +27,13 @@ function ChatSection() { }>({}); const handleSendMessage = async (payload: stepPayload | { message: string; userId: string }) => { - // Typing 임시메시지 + const tempTypingId = `typing-${Date.now()}`; + + // Typing 메시지 임시 추가 setMessages((prev) => [ ...prev, { - id: 'typing', + id: tempTypingId, sender: 'CHATBOT', type: 'TYPING', message: '', @@ -50,11 +49,10 @@ function ChatSection() { if (!botMessage) return; - // Typing 임시메시지 제거 후 실제 메시지 추가 - setMessages((prev) => [...prev.filter((m) => m.type !== 'TYPING'), botMessage]); + setMessages((prev) => prev.map((msg) => (msg.id === tempTypingId ? botMessage : msg))); } catch (err) { console.error(err); - setMessages((prev) => prev.filter((m) => m.type !== 'TYPING')); + setMessages((prev) => prev.filter((msg) => msg.id !== tempTypingId)); } }; @@ -166,7 +164,6 @@ function ChatSection() { userCurrentStep={userCurrentStep} onSelectedOption={handleSelectedOption} getRecommendations={getRecommendations} - isBotTyping={isBotTyping} /> diff --git a/src/domains/recommend/components/bot/BotMessage.tsx b/src/domains/recommend/components/bot/BotMessage.tsx index 261ed1d..2155259 100644 --- a/src/domains/recommend/components/bot/BotMessage.tsx +++ b/src/domains/recommend/components/bot/BotMessage.tsx @@ -18,36 +18,70 @@ interface BotMessage { interface BotMessages { messages: BotMessage[]; + showProfile: boolean; stepData?: StepRecommendation | null; currentStep?: number; - children?: React.ReactNode; onSelectedOption?: (value: string) => void; + isTyping?: boolean; } -function BotMessage({ messages, stepData, currentStep, onSelectedOption, children }: 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.type === 'TYPING' ? ( - - ) : ( - // 실제 메시지 내용 -

      {msg.message}

      - )} - - {/* radio */} - {msg.type === 'RADIO_OPTIONS' && msg.options?.length && ( - { - setSelected(val); - onSelectedOption?.(val); - }} - /> - )} - {/* {children} */} -
      + '' )}
))} diff --git a/src/domains/recommend/components/user/UserMessage.tsx b/src/domains/recommend/components/user/UserMessage.tsx index f63a1ed..4712862 100644 --- a/src/domains/recommend/components/user/UserMessage.tsx +++ b/src/domains/recommend/components/user/UserMessage.tsx @@ -1,5 +1,6 @@ interface Props { message: string; + showProfile: boolean; } // 메시지 (연속 메시지) 예시.. @@ -12,12 +13,14 @@ interface Props { // { id: '2', sender: 'user', text: '배고파요' }, // ]; -function UserMessage({ message }: Props) { +function UserMessage({ message, showProfile }: Props) { return (
-
- -
+ {showProfile && ( +
+ +
+ )} {/* 메시지 그룹 */}
diff --git a/src/domains/recommend/types/recommend.ts b/src/domains/recommend/types/recommend.ts index 2ee1dde..f764004 100644 --- a/src/domains/recommend/types/recommend.ts +++ b/src/domains/recommend/types/recommend.ts @@ -28,6 +28,7 @@ export interface ChatMessage { type?: string; stepData?: StepRecommendation | null; createdAt: string; + tempTyping?: boolean; } export interface ChatHistoryItem { @@ -59,6 +60,5 @@ export interface ChatListProps { type: string | undefined, stepData?: StepRecommendation | null ) => RecommendationItem[]; - - isBotTyping: boolean; + isBotTyping?: boolean; } From 67dac96d258b4f4f60e5aa7a31eef39c69682baf Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Sat, 4 Oct 2025 19:39:02 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[feat]=20input=20disabled=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/recommend/components/ChatSection.tsx | 5 ++++- .../recommend/components/user/MessageInput.tsx | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/domains/recommend/components/ChatSection.tsx b/src/domains/recommend/components/ChatSection.tsx index bc00706..e959cd5 100644 --- a/src/domains/recommend/components/ChatSection.tsx +++ b/src/domains/recommend/components/ChatSection.tsx @@ -26,6 +26,9 @@ function ChatSection() { selectedCocktailType?: string; }>({}); + const isInputDisabled = + selectedOptions.current.selectedCocktailType !== 'QA' && userCurrentStep < 3; + const handleSendMessage = async (payload: stepPayload | { message: string; userId: string }) => { const tempTypingId = `typing-${Date.now()}`; @@ -165,7 +168,7 @@ function ChatSection() { onSelectedOption={handleSelectedOption} getRecommendations={getRecommendations} /> - + ); } diff --git a/src/domains/recommend/components/user/MessageInput.tsx b/src/domains/recommend/components/user/MessageInput.tsx index 4b8c228..d893b29 100644 --- a/src/domains/recommend/components/user/MessageInput.tsx +++ b/src/domains/recommend/components/user/MessageInput.tsx @@ -7,9 +7,10 @@ import { useRef, useState } from 'react'; interface Props { onSubmit: (message: string) => 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 + `} />