feat(chat): 프론트 로드 에러 페이지·대기 애니메이션·indicator 소비
- ErrorPage 컴포넌트 추가: init/messages 로드 실패(서버/네트워크) 시 ChatContainer 가 에러 화면+재시도(refetch) 렌더 (기존엔 빈 채팅처럼 보임) - 대기 표시: 채팅 영역 '협상 중' 타이핑 버블 + 입력 버튼 자리 '...' 점 애니메이션 - desync 에러코드(CHAT_INPUT_MODE_MISMATCH 1404 / CHAT_AGENT_TIMEOUT 1405) 추가 및 메시지 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f070e362b
commit
f7f5439042
@ -41,6 +41,8 @@ export const ErrorCode = {
|
|||||||
CHAT_PRICE_OUT_OF_RANGE: 1401,
|
CHAT_PRICE_OUT_OF_RANGE: 1401,
|
||||||
CHAT_AGENT_UNAVAILABLE: 1402,
|
CHAT_AGENT_UNAVAILABLE: 1402,
|
||||||
CHAT_IN_PROGRESS: 1403,
|
CHAT_IN_PROGRESS: 1403,
|
||||||
|
CHAT_INPUT_MODE_MISMATCH: 1404,
|
||||||
|
CHAT_AGENT_TIMEOUT: 1405,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
|
||||||
@ -62,6 +64,8 @@ const API_ERROR_MESSAGES: Record<number, string> = {
|
|||||||
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
|
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
|
||||||
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||||
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
|
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
|
||||||
|
[ErrorCode.CHAT_INPUT_MODE_MISMATCH]: '입력 형식이 맞지 않습니다. 최신 대화 상태로 다시 불러옵니다.',
|
||||||
|
[ErrorCode.CHAT_AGENT_TIMEOUT]: '협상 응답이 지연되고 있습니다. 최신 상태를 다시 불러옵니다.',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */
|
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */
|
||||||
|
|||||||
37
frontend/src/components/ErrorPage.tsx
Normal file
37
frontend/src/components/ErrorPage.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
|
||||||
|
export interface ErrorPageProps {
|
||||||
|
/** 상단 메시지(굵게). 기본: 일반 오류 문구. */
|
||||||
|
message?: string
|
||||||
|
/** 보조 설명. 기본: 재시도 안내. */
|
||||||
|
description?: string
|
||||||
|
/** 재시도 버튼 핸들러. 없으면 버튼 미표시. */
|
||||||
|
onRetry?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 로드 실패(서버/네트워크 오류) 시 보여주는 에러 화면. 컨테이너를 가득 채운다(h-full).
|
||||||
|
export function ErrorPage({
|
||||||
|
message = '문제가 발생했습니다.',
|
||||||
|
description = '잠시 후 다시 시도해주세요.',
|
||||||
|
onRetry,
|
||||||
|
}: ErrorPageProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-6 p-12">
|
||||||
|
<div className="flex size-20 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<AlertTriangle className="size-10 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="title-2 text-destructive">{message}</p>
|
||||||
|
<p className="body-1 text-neutral-70">{description}</p>
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<Button variant="primary" size="lg" onClick={onRetry}>
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,3 +5,5 @@ export { Modal } from '@/components/Modal'
|
|||||||
export type { ModalProps } from '@/components/Modal'
|
export type { ModalProps } from '@/components/Modal'
|
||||||
export { Logo } from '@/components/Logo'
|
export { Logo } from '@/components/Logo'
|
||||||
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
||||||
|
export { ErrorPage } from '@/components/ErrorPage'
|
||||||
|
export type { ErrorPageProps } from '@/components/ErrorPage'
|
||||||
|
|||||||
@ -21,10 +21,12 @@ export function ChatMessage() {
|
|||||||
function ChatList() {
|
function ChatList() {
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
const chats = useChatStore((s) => s.messages)
|
const chats = useChatStore((s) => s.messages)
|
||||||
|
const isLoading = useChatStore((s) => s.isLoading)
|
||||||
|
|
||||||
|
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [chats])
|
}, [chats, isLoading])
|
||||||
|
|
||||||
if (!chats || chats.length === 0) {
|
if (!chats || chats.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -39,11 +41,25 @@ function ChatList() {
|
|||||||
{chats.map((message, index) => (
|
{chats.map((message, index) => (
|
||||||
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
|
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
|
||||||
))}
|
))}
|
||||||
|
{isLoading && <TypingBubble />}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</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({
|
const MessageItem = memo(function MessageItem({
|
||||||
message,
|
message,
|
||||||
isFirst,
|
isFirst,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { List, Loader2 } from 'lucide-react'
|
import { List } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { Percent, Price } from '@/features/chat/components/userInputs'
|
import { Percent, Price } from '@/features/chat/components/userInputs'
|
||||||
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
||||||
@ -31,7 +32,7 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
|||||||
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
|
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
|
||||||
)}
|
)}
|
||||||
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
||||||
{type === 'loading' && <Loader2 className="size-8 animate-spin text-neutral-60" />}
|
{type === 'loading' && <LoadingDots />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<GoToList />
|
<GoToList />
|
||||||
@ -41,6 +42,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agent 응답 대기 중(협상 중) 버튼 자리에 표시하는 "..." 점 애니메이션. 비활성(클릭 불가).
|
||||||
|
function LoadingDots() {
|
||||||
|
return (
|
||||||
|
<div className={cn(style.black, 'cursor-default pointer-events-none')} aria-label="협상 중" role="status">
|
||||||
|
<span className="flex gap-[6px]">
|
||||||
|
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.3s]" />
|
||||||
|
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.15s]" />
|
||||||
|
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function GoToList() {
|
function GoToList() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,10 +1,30 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { ErrorPage } from '@/components'
|
||||||
import { useChatController } from '@/features/chat/hooks/useChatController'
|
import { useChatController } from '@/features/chat/hooks/useChatController'
|
||||||
import { ChatSection } from '@/features/chat/components/ChatSection'
|
import { ChatSection } from '@/features/chat/components/ChatSection'
|
||||||
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
|
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
|
||||||
|
|
||||||
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
|
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
|
||||||
|
// 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도).
|
||||||
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
||||||
useChatController(sessionId)
|
const { isInitLoading, initError, refetchInit } = useChatController(sessionId)
|
||||||
|
|
||||||
|
if (isInitLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center w-full">
|
||||||
|
<Loader2 className="size-10 animate-spin text-neutral-50" aria-label="불러오는 중" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 w-full">
|
||||||
|
<ErrorPage message="채팅을 불러오는 중 오류가 발생했습니다." onRetry={refetchInit} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 w-full">
|
<div className="flex flex-1 min-h-0 w-full">
|
||||||
<ChatSection />
|
<ChatSection />
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useChatInitQuery, useChatMessagesQuery, useChatSendMutation, mapMessage } from '@/apis/chat'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
useChatInitQuery,
|
||||||
|
useChatMessagesQuery,
|
||||||
|
useChatSendMutation,
|
||||||
|
mapMessage,
|
||||||
|
chatKeys,
|
||||||
|
} from '@/apis/chat'
|
||||||
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
|
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
|
||||||
import { toast } from '@/lib'
|
import { toast } from '@/lib'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
@ -16,6 +23,12 @@ const TERMINAL_CODES = new Set<number>([
|
|||||||
ErrorCode.NEGO_NOT_FOUND,
|
ErrorCode.NEGO_NOT_FOUND,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크.
|
||||||
|
const RESYNC_CODES = new Set<number>([
|
||||||
|
ErrorCode.CHAT_INPUT_MODE_MISMATCH, // 보낸 입력이 직전 봇이 요구한 모드와 불일치
|
||||||
|
ErrorCode.CHAT_AGENT_TIMEOUT, // agent 타임아웃(서버/agent 상태가 앞서 있을 수 있음)
|
||||||
|
])
|
||||||
|
|
||||||
let tempSeq = 0
|
let tempSeq = 0
|
||||||
|
|
||||||
// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다.
|
// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다.
|
||||||
@ -42,6 +55,7 @@ function makeUserMessage(text: string, inputType: UserInputType): ChatMessage {
|
|||||||
*/
|
*/
|
||||||
export function useChatController(sessionId: string) {
|
export function useChatController(sessionId: string) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const setInitData = useChatInitStore((s) => s.setInitData)
|
const setInitData = useChatInitStore((s) => s.setInitData)
|
||||||
const initQuery = useChatInitQuery(sessionId)
|
const initQuery = useChatInitQuery(sessionId)
|
||||||
const messagesQuery = useChatMessagesQuery(sessionId)
|
const messagesQuery = useChatMessagesQuery(sessionId)
|
||||||
@ -94,6 +108,12 @@ export function useChatController(sessionId: string) {
|
|||||||
|
|
||||||
toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.'))
|
toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.'))
|
||||||
|
|
||||||
|
// 입력-모드 불일치/타임아웃: 서버 기준으로 대화를 다시 불러와 화면을 리싱크한다.
|
||||||
|
// (messages 쿼리가 갱신되면 위 effect 가 스토어 messages 를 덮어써 버튼/입력창이 서버 상태에 맞춰진다.)
|
||||||
|
if (RESYNC_CODES.has(code)) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: chatKeys.messages(sessionId) })
|
||||||
|
}
|
||||||
|
|
||||||
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
|
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
|
||||||
if (TERMINAL_CODES.has(code)) {
|
if (TERMINAL_CODES.has(code)) {
|
||||||
s.bindSend(null) // 입력 잠금(추가 전송 차단)
|
s.bindSend(null) // 입력 잠금(추가 전송 차단)
|
||||||
@ -107,10 +127,15 @@ export function useChatController(sessionId: string) {
|
|||||||
return () => {
|
return () => {
|
||||||
useChatStore.getState().reset()
|
useChatStore.getState().reset()
|
||||||
}
|
}
|
||||||
}, [sessionId, sendMutate, navigate])
|
}, [sessionId, sendMutate, navigate, queryClient])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isInitLoading: initQuery.isLoading || messagesQuery.isLoading,
|
isInitLoading: initQuery.isLoading || messagesQuery.isLoading,
|
||||||
initError: initQuery.error ?? messagesQuery.error,
|
initError: initQuery.error ?? messagesQuery.error,
|
||||||
|
// 로드 실패(서버/네트워크) 시 init·messages 를 함께 재조회한다.
|
||||||
|
refetchInit: () => {
|
||||||
|
void initQuery.refetch()
|
||||||
|
void messagesQuery.refetch()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user