- 공급사 포털: 채팅 말풍선·남은시간 표기·헤더바·채팅 화면 정비 - landing: how-it-works 데모를 GIF 재생으로 되돌리고 히어로·디바이스 목업 조정, negotiation_annotated_3d 미디어 갱신
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
import { useEffect, useRef, memo, type RefObject } from 'react'
|
|
import { useMeQuery } from '@/apis'
|
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
|
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
|
|
import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
|
import { ExtraInfoForm } from '@/features/chat/components/templates/ExtraInfoForm'
|
|
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
|
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
|
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
|
|
|
const AI_LABEL = '아이마켓코리아 (구매 MD)'
|
|
|
|
export function ChatMessage() {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
return (
|
|
<div className="flex-1 w-full min-h-0">
|
|
{/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선
|
|
스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다.
|
|
스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="chat-scroll h-full overflow-y-auto flex flex-col pt-0 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
|
|
>
|
|
<ChatList scrollRef={scrollRef} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }) {
|
|
const isFirstRef = useRef(true)
|
|
const chats = useChatStore((s) => s.messages)
|
|
const isLoading = useChatStore((s) => s.isLoading)
|
|
|
|
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤.
|
|
// 스크롤 컨테이너를 직접 내린다 — scrollIntoView 는 요약/부가정보 폼처럼 큰 블록이 뒤늦게
|
|
// 레이아웃되면 애니메이션 도중 목표 위치가 밀려 중간에 멈춘다.
|
|
// 진입 첫 렌더는 즉시(auto) — 히스토리를 복원하며 위에서부터 훑어 내려가는 연출을 막는다.
|
|
useEffect(() => {
|
|
const scroller = scrollRef.current
|
|
if (!scroller) return
|
|
const behavior: ScrollBehavior = isFirstRef.current ? 'auto' : 'smooth'
|
|
isFirstRef.current = false
|
|
const id = requestAnimationFrame(() => {
|
|
scroller.scrollTo({ top: scroller.scrollHeight, behavior })
|
|
})
|
|
return () => cancelAnimationFrame(id)
|
|
}, [chats, isLoading])
|
|
|
|
if (!chats || chats.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<p className="text-sm text-neutral-60">채팅 내역이 없습니다.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col w-full">
|
|
{chats.map((message, index) => (
|
|
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
|
))}
|
|
{isLoading && <TypingBubble />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터(AI 말풍선 톤).
|
|
function TypingBubble() {
|
|
return (
|
|
<div className="mb-6 flex flex-col items-start gap-1.5" aria-label="협상 중" role="status">
|
|
<span className="text-xs font-bold text-neutral-80">{AI_LABEL}</span>
|
|
<div className="inline-flex items-center gap-1.5 rounded-2xl rounded-tl-none border border-[#D2E6FF] bg-brand-light/70 px-4 py-3.5 shadow-sm">
|
|
<span className="size-2 rounded-full bg-brand-500 animate-bounce [animation-delay:-0.3s]" />
|
|
<span className="size-2 rounded-full bg-brand-500 animate-bounce [animation-delay:-0.15s]" />
|
|
<span className="size-2 rounded-full bg-brand-500 animate-bounce" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MessageItem = memo(function MessageItem({
|
|
message,
|
|
messages,
|
|
currentIndex,
|
|
}: {
|
|
message: ChatMessageType
|
|
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 null
|
|
}
|
|
}
|
|
|
|
return isBot ? <BotMessage message={message} /> : <UserMessage text={message.script || ''} />
|
|
})
|
|
|
|
const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageType }) {
|
|
// 인디케이터는 이번 방문에서 '협상중'이었던 세션에서만 표시.
|
|
const showIndicator = useChatInitStore((s) => s.wasInProgress)
|
|
const hasScript = Boolean(message.script && message.script.trim())
|
|
|
|
return (
|
|
<div className="mb-5 animate-fade-in">
|
|
<div className="flex flex-col items-start gap-1.5">
|
|
<span className="text-xs font-bold text-neutral-80">{AI_LABEL}</span>
|
|
{hasScript && (
|
|
<div className="max-w-[80%] rounded-2xl rounded-tl-none border border-[#D2E6FF] bg-brand-light/70 p-4 text-sm leading-relaxed text-neutral-90 shadow-sm break-keep">
|
|
{renderEmphasis(message.script || '')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 w-full mt-4">
|
|
{showIndicator && 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} />
|
|
<ExtraInfoForm />
|
|
</>
|
|
)}
|
|
{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 }) {
|
|
const { data: user } = useMeQuery()
|
|
const label = `${user?.supplierName ?? '공급사'} (나)`
|
|
return (
|
|
<div className="mb-5 flex flex-col items-end gap-1.5 animate-fade-in">
|
|
<span className="text-xs font-bold text-neutral-80">{label}</span>
|
|
<div className="max-w-[80%] rounded-2xl rounded-tr-none border border-border bg-white p-4 text-sm leading-relaxed text-neutral-90 shadow-sm break-keep">
|
|
{text}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|