- 상품목록 버튼 래퍼에 pr-[16px] 추가 — 채팅 스크롤바 오른쪽 끝과 정렬 - 상단 pt-[64px] 를 스크롤 컨테이너 안쪽으로 이동 — 첫 메시지는 협상절차 카드와 높이 유지, 스크롤 시 여백 없이 맨 위까지 표시 - chat-scroll 스크롤바 트랙에 margin-top 64px — 스크롤바 시작 높이를 협상절차 카드 상단과 일치 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
import { useEffect, useRef, memo } from 'react'
|
|
import { cn } from '@/lib'
|
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
|
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
|
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
|
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
|
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
|
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
|
|
|
export function ChatMessage() {
|
|
return (
|
|
<div className="flex-1 w-full min-h-0 pr-[16px]">
|
|
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
|
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pl-[140px] pr-[126px]">
|
|
<ChatList />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ChatList() {
|
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
const chats = useChatStore((s) => s.messages)
|
|
const isLoading = useChatStore((s) => s.isLoading)
|
|
|
|
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [chats, isLoading])
|
|
|
|
if (!chats || chats.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<p className="body-1 text-neutral-70">채팅 내역이 없습니다.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col w-full">
|
|
{chats.map((message, index) => (
|
|
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
|
|
))}
|
|
{isLoading && <TypingBubble />}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터.
|
|
function TypingBubble() {
|
|
return (
|
|
<div className="mb-[56px] pt-[36px]" aria-label="협상 중" role="status">
|
|
<div className="inline-flex items-center gap-[6px]">
|
|
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.3s]" />
|
|
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.15s]" />
|
|
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MessageItem = memo(function MessageItem({
|
|
message,
|
|
isFirst,
|
|
messages,
|
|
currentIndex,
|
|
}: {
|
|
message: ChatMessageType
|
|
isFirst: boolean
|
|
messages: ChatMessageType[]
|
|
currentIndex: number
|
|
}) {
|
|
const isBot = message.sender === 'bot'
|
|
|
|
// 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시
|
|
if (!isBot && currentIndex > 0) {
|
|
const prev = messages[currentIndex - 1]
|
|
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
|
return (
|
|
<>
|
|
<div className="text-right mb-[56px]" />
|
|
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
|
|
</>
|
|
)
|
|
}
|
|
}
|
|
|
|
return <div>{isBot ? <BotMessage message={message} isFirst={isFirst} /> : <UserMessage text={message.script || ''} />}</div>
|
|
})
|
|
|
|
const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) {
|
|
return (
|
|
<div className="mb-[56px]">
|
|
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
|
|
<div className="body-1-read-r">{message.script || ''}</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 w-full mt-[32px]">
|
|
{message.bot_chat_type === 'indicator' && message.indicator_value != null && (
|
|
<Indicator number={message.indicator_value} />
|
|
)}
|
|
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
|
|
{message.bot_chat_type === 'summaryCM' && message.summary && (
|
|
<BidSummary
|
|
itemName={message.summary.item_name}
|
|
itemCode={message.summary.item_code}
|
|
bidPrice={message.summary.final_price}
|
|
deliveryType={message.summary.delivery_type || ''}
|
|
isVAT={message.summary.item_isVAT}
|
|
/>
|
|
)}
|
|
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
|
|
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const UserMessage = memo(function UserMessage({ text }: { text: string }) {
|
|
return (
|
|
<>
|
|
<div className="text-right mb-[56px]">
|
|
<div className="inline-block max-w-[87%] bg-neutral-40 text-neutral-90 text-lg leading-[150%] tracking-[-0.18px] px-[20px] py-[10px] rounded-full break-keep">
|
|
{text}
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
|
|
</>
|
|
)
|
|
})
|