diff --git a/src/app/mypage/my-bar/page.tsx b/src/app/mypage/my-bar/page.tsx index 63d3e8e..4c12cc4 100644 --- a/src/app/mypage/my-bar/page.tsx +++ b/src/app/mypage/my-bar/page.tsx @@ -1,4 +1,3 @@ - import CocktailCard from '@/domains/shared/components/cocktail-card/CocktailCard'; import { Metadata } from 'next'; diff --git a/src/app/recommend/page.tsx b/src/app/recommend/page.tsx index 8ff49e0..c49c438 100644 --- a/src/app/recommend/page.tsx +++ b/src/app/recommend/page.tsx @@ -4,7 +4,7 @@ import Bg from '@/shared/assets/images/recommend_bg.webp'; function Page() { return (

취향추천하기

diff --git a/src/domains/recipe/details/DetailItem.tsx b/src/domains/recipe/details/DetailItem.tsx index b803c8a..0eb6023 100644 --- a/src/domains/recipe/details/DetailItem.tsx +++ b/src/domains/recipe/details/DetailItem.tsx @@ -4,8 +4,6 @@ import Label from '@/domains/shared/components/label/Label'; import AbvGraph from '@/domains/shared/components/abv-graph/AbvGraph'; import { labelTitle } from '../utills/labelTitle'; - - interface Props { name: string; nameKo: string; diff --git a/src/domains/recommend/api/chat.ts b/src/domains/recommend/api/chat.ts new file mode 100644 index 0000000..652b7e0 --- /dev/null +++ b/src/domains/recommend/api/chat.ts @@ -0,0 +1,125 @@ +import { getApi } from '@/app/api/config/appConfig'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { ChatHistoryItem, ChatMessage, stepPayload, TextPayload } from '../types/recommend'; + +// 첫 시작 api +export const fetchGreeting = async (message: string): Promise => { + try { + const userId = useAuthStore.getState().user?.id; + if (!userId) throw new Error('userId 없음.'); + + const res = await fetch(`${getApi}/chatbot/greeting/${userId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + credentials: 'include', + }); + + if (!res.ok) { + console.error('API 요청 실패:', res.status, res.statusText); + return null; + } + + const data = await res.json(); + + return { + id: String(data.data.id), + userId, + message: data.data.message, + sender: data.data.sender ?? 'CHATBOT', + type: data.data.type, + stepData: data.data.stepData ?? null, + createdAt: data.data.timestamp, + }; + } catch (err) { + console.error('Greeting API 호출 에러:', err); + return null; + } +}; + +// 유저 메시지 전송 (일반 텍스트) +export const fetchSendTextMessage = async (payload: TextPayload): Promise => { + try { + const res = await fetch(`${getApi}/chatbot/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + + if (!res.ok) return null; + const data = await res.json(); + + return { + id: String(data.data.id), + userId: data.data.userId ?? payload.userId, + message: data.data.message, + sender: data.data.sender ?? 'CHATBOT', + type: data.data.type, + stepData: data.data.stepData ?? null, + createdAt: data.data.timestamp, + }; + } catch (err) { + console.error('Text 메시지 전송 실패:', err); + return null; + } +}; + +// 단계별 옵션 메시지 전송 +export const fetchSendStepMessage = async (payload: stepPayload): Promise => { + try { + const res = await fetch(`${getApi}/chatbot/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + + if (!res.ok) return null; + const data = await res.json(); + + return { + id: String(data.data.id), + userId: data.data.userId, + message: data.data.message, + sender: data.data.sender ?? 'CHATBOT', + type: data.data.type, + stepData: data.data.stepData ?? null, + createdAt: data.data.timestamp, + }; + } catch (err) { + console.error('Step 메시지 전송 실패:', err); + return null; + } +}; + +export const fetchChatHistory = async (): Promise => { + try { + const userId = useAuthStore.getState().user?.id; + if (!userId) throw new Error('userId 없음'); + + const res = await fetch(`${getApi}/chatbot/history/user/${userId}`, { + method: 'GET', + credentials: 'include', + }); + + if (!res.ok) { + console.error('History API 요청 실패:', res.status, res.statusText); + return null; + } + + const data = await res.json(); + + return data.data.map((item: ChatHistoryItem) => ({ + id: String(item.id), + userId: item.userId, + message: item.message, + sender: item.sender, + stepData: item.stepData ?? null, + createdAt: item.timestamp, + })); + } catch (err) { + console.error('History API 호출 에러:', err); + return null; + } +}; diff --git a/src/domains/recommend/components/ChatList.tsx b/src/domains/recommend/components/ChatList.tsx new file mode 100644 index 0000000..56eb6f3 --- /dev/null +++ b/src/domains/recommend/components/ChatList.tsx @@ -0,0 +1,62 @@ +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({ + messages, + userCurrentStep, + onSelectedOption, + getRecommendations, + chatListRef, + chatEndRef, + showNewMessageAlert, + handleCheckBottom, + handleScrollToBottom, + isBotTyping, +}: ChatListProps) { + return ( +
+
+ {messages.map((msg, idx) => { + const isLastMessage = idx === messages.length - 1; + const showTyping = isLastMessage && msg.sender === 'CHATBOT' && isBotTyping; + + if (msg.sender === 'USER') { + return ; + } + + return ( + + ); + })} + + {isBotTyping && } + +
+ {showNewMessageAlert && } +
+
+ ); +} + +export default ChatList; diff --git a/src/domains/recommend/components/ChatSection.tsx b/src/domains/recommend/components/ChatSection.tsx index ad39ccf..2f29080 100644 --- a/src/domains/recommend/components/ChatSection.tsx +++ b/src/domains/recommend/components/ChatSection.tsx @@ -5,85 +5,161 @@ import BotMessage from './bot/BotMessage'; import UserMessage from './user/UserMessage'; import NewMessageAlert from './bot/NewMessageAlert'; import MessageInput from './user/MessageInput'; - -// TODOS : 아직 api 몰라서 임시 type -interface ChatMessage { - id: number; - message: string; - sender: 'user' | 'bot'; -} +import { useChatScroll } from '../hook/useChatScroll'; +import { + fetchChatHistory, + fetchGreeting, + fetchSendStepMessage, + fetchSendTextMessage, +} from '../api/chat'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { + ChatMessage, + stepPayload, + StepRecommendation, + RecommendationItem, +} from '../types/recommend'; +import ChatList from './ChatList'; function ChatSection() { const [messages, setMessages] = useState([]); - const chatEndRef = useRef(null); - const chatListRef = useRef(null); - const isScrollBottom = useRef(true); - const [showNewMessageAlert, setShowNewMessageAlert] = useState(false); - - const handleSubmit = (message: string) => { - // 사용자 메시지 - setMessages((prev) => [...prev, { id: prev.length + 1, message, sender: 'user' }]); - }; + const { chatListRef, chatEndRef, showNewMessageAlert, handleCheckBottom, handleScrollToBottom } = + useChatScroll(messages.length); + const [userCurrentStep, setUserCurrentStep] = useState(0); + const [isBotTyping, setIsBotTyping] = useState(false); - // 쑤리 임시 메시지 - // useEffect(() => { - // const interval = setInterval(() => { - // setMessages((prev) => [ - // ...prev, - // { id: prev.length + 1, message: `새 메시지 ${prev.length + 1}`, sender: 'bot' }, - // ]); - // }, 1000); + const selectedOptions = useRef<{ + selectedAlcoholStrength?: string; + selectedAlcoholBaseType?: string; + selectedCocktailType?: string; + }>({}); - // return () => clearInterval(interval); - // }, []); + // 일반 텍스트 보낼 시 + const handleSubmitText = async (message: string) => { + const userId = useAuthStore.getState().user?.id; + if (!userId) return; - // 스크롤 제일 아래인지 체크 - const handleCheckBottom = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const tempId = Date.now().toString(); + const tempCreatedAt = new Date().toISOString(); - isScrollBottom.current = scrollTop + clientHeight >= scrollHeight - 10; + // 유저 메시지 낙관적 업데이트 + setMessages((prev) => [ + ...prev, + { id: tempId, userId, message, sender: 'USER', type: 'text', createdAt: tempCreatedAt }, + ]); - if (isScrollBottom.current) setShowNewMessageAlert(false); + const botMessage = await fetchSendTextMessage({ message, userId }); + if (botMessage) setMessages((prev) => [...prev, botMessage]); }; - // 새 메시지가 들어오면 자동 스크롤 - useEffect(() => { - if (isScrollBottom.current) { - chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - setShowNewMessageAlert(false); // 새메세지 숨김 - } else { - setShowNewMessageAlert(true); // 새메세지 보여줌 + // 옵션 클릭 시 + const handleSelectedOption = async (value: string) => { + const userId = useAuthStore.getState().user?.id; + if (!userId) return; + + const tempId = Date.now().toString(); + const tempCreatedAt = new Date().toISOString(); + + const lastMessage = messages[messages.length - 1]; + const stepData = lastMessage?.stepData; + + if (!stepData) { + await handleSubmitText(value); + return; + } + + const selectedLabel = stepData.options?.find((opt) => opt.value === value)?.label ?? value; + + setMessages((prev) => [ + ...prev, + { + id: tempId, + userId, + message: selectedLabel, + sender: 'USER', + type: 'text', + createdAt: tempCreatedAt, + }, + ]); + + 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; + case 4: + selectedOptions.current.selectedCocktailType = value; + break; } - }, [messages]); - // 스크롤 제일 아래로 - const handleScrollToBottom = () => { - if (chatListRef.current) { - chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - isScrollBottom.current = true; + const payload: stepPayload = { + message: selectedLabel, + userId, + currentStep: nextStep, + ...selectedOptions.current, + }; + + const typingTimer = setTimeout(() => setIsBotTyping(true), 300); + + try { + const botMessage = await fetchSendStepMessage(payload); + + clearTimeout(typingTimer); + setIsBotTyping(false); + + if (botMessage) { + setMessages((prev) => [...prev, botMessage]); + } + } catch (err) { + clearTimeout(typingTimer); + setIsBotTyping(false); + console.error(err); } }; + // 채팅 기록 불러오기 없으면 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; + }; + return ( -
+

대화 목록 및 입력 창

-
- {messages.map(({ id, message, sender }) => - sender === 'user' ? ( - - ) : ( - - ) - )} - -
- {showNewMessageAlert && } -
- + +
); } diff --git a/src/domains/recommend/components/bot/BotCocktailCard.tsx b/src/domains/recommend/components/bot/BotCocktailCard.tsx index 6c861df..c5e6b8f 100644 --- a/src/domains/recommend/components/bot/BotCocktailCard.tsx +++ b/src/domains/recommend/components/bot/BotCocktailCard.tsx @@ -1,17 +1,31 @@ import Image from 'next/image'; import Link from 'next/link'; import Keep from '@/domains/shared/components/keep/Keep'; +import { RecommendationItem } from '../../types/recommend'; -function BotCocktailCard() { +function BotCocktailCard({ + cocktailId, + cocktailName, + cocktailNameKo, + cocktailImgUrl, + alcoholStrength, +}: RecommendationItem) { return (
- 칵테일 이름 + {cocktailNameKo}
- {'진피즈'} + {cocktailNameKo} + 상세보기
diff --git a/src/domains/recommend/components/bot/BotMessage.tsx b/src/domains/recommend/components/bot/BotMessage.tsx index b3f7655..d74afde 100644 --- a/src/domains/recommend/components/bot/BotMessage.tsx +++ b/src/domains/recommend/components/bot/BotMessage.tsx @@ -5,28 +5,25 @@ import Image from 'next/image'; import { useState } from 'react'; import BotCocktailCard from './BotCocktailCard'; import BotOptions from './BotOptions'; -import TypingIndicator from './TypingIndicator'; +import { StepOption, StepRecommendation, RecommendationItem } from '../../types/recommend'; -interface Message { - id: number; - message?: string; - type?: 'radio' | 'text' | 'recommend'; +interface BotMessage { + id: string; + message: string; + type: string; + options?: StepOption[]; + recommendations?: RecommendationItem[]; } interface BotMessages { - messages: Message[]; - isTyping?: boolean; + messages: BotMessage[]; + stepData?: StepRecommendation | null; + currentStep?: number; + onSelectedOption?: (value: string) => void; } -function BotMessage({ messages, isTyping = false }: BotMessages) { - const [selected, setSelected] = useState('option1'); - - // 임시 radio 옵션 - const options = [ - { label: '옵션 1', value: 'option1' }, - { label: '옵션 2', value: 'option2' }, - { label: '옵션 3', value: 'option3' }, - ]; +function BotMessage({ messages, stepData, currentStep, onSelectedOption }: BotMessages) { + const [selected, setSelected] = useState(''); return (
@@ -47,31 +44,41 @@ function BotMessage({ messages, isTyping = false }: BotMessages) {
{messages.map((msg) => (
- {msg.type === 'recommend' ? ( + {msg.type === 'CARD_LIST' && msg.recommendations?.length ? (
    -
  • - -
  • -
  • - -
  • -
  • - -
  • + {msg.recommendations.map((rec) => ( +
  • + +
  • + ))}
) : (
{msg.message &&

{msg.message}

} {/* radio */} - {msg.type === 'radio' && ( - + {msg.type === 'RADIO_OPTIONS' && msg.options?.length && ( + { + setSelected(val); + onSelectedOption?.(val); + }} + /> )}
)}
))} - {isTyping && }
); diff --git a/src/domains/recommend/components/bot/BotOptions.tsx b/src/domains/recommend/components/bot/BotOptions.tsx index 68fafee..7e6040c 100644 --- a/src/domains/recommend/components/bot/BotOptions.tsx +++ b/src/domains/recommend/components/bot/BotOptions.tsx @@ -3,34 +3,41 @@ interface Option { value: string; } -interface RadioGroupProps { +interface BotOptionsProps { options: Option[]; value: string; onChange: (value: string) => void; + step: number; + currentStep: number; } -function BotOptions({ options, value, onChange }: RadioGroupProps) { +function BotOptions({ options, value, onChange, step, currentStep }: BotOptionsProps) { return (
{options.map((opt) => (