Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/components/chat/ChatRoomCreateError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import Button from "@/components/common/Button/Button";
import { Text } from "@/components/common/Text";
import { InfoIcon } from "@/icons";
import { cn } from "@/lib/utils/cn";

export interface ChatRoomCreateErrorProps {
/** API/네트워크 등에서 전달된 실패 사유 */
message?: string;
isRetrying?: boolean;
onRetry: () => void;
className?: string;
}

const DEFAULT_MESSAGE = "일시적인 오류로 채팅방을 열 수 없습니다.";

/**
* 채팅방 생성/입장 실패 시 모달 내부 안내 + 재시도
* // 2026.08.08 김성현 - [추가] 권한·네트워크·서버·견적상태 실패 공통 UI
*/
export default function ChatRoomCreateError({
message = DEFAULT_MESSAGE,
isRetrying = false,
onRetry,
className,
}: ChatRoomCreateErrorProps) {
return (
<div
role="alert"
className={cn(
"flex h-full w-full flex-col items-center justify-center gap-16 px-24 py-40 text-center",
className,
)}
>
<div
className="bg-background-brand-muted text-icon-brand flex size-64 items-center justify-center rounded-full"
aria-hidden="true"
>
<InfoIcon className="size-28" />
</div>

<div className="flex flex-col items-center gap-8">
<Text as="p" variant="lg-semibold" className="text-text-secondary">
채팅방을 열 수 없습니다
</Text>
<Text as="p" variant="md-regular" className="text-text-muted whitespace-pre-line">
{message.trim() || DEFAULT_MESSAGE}
</Text>
</div>

<Button
type="button"
variant="solid"
size="cta"
className="min-w-[200px]"
disabled={isRetrying}
aria-busy={isRetrying}
onClick={onRetry}
>
{isRetrying ? "다시 시도 중..." : "다시 시도"}
</Button>

<Text as="p" variant="xs-regular" className="text-text-muted">
문제가 계속되면 잠시 후 다시 시도해 주세요.
</Text>
</div>
);
}
5 changes: 3 additions & 2 deletions src/components/chat/ChatRoomModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function ChatRoomModalContent({
actions,
}: ChatRoomModalContentProps) {
const [isActionSheetOpen, setIsActionSheetOpen] = useState(false);
const isSendDisabled = sendDisabled || !messageValue.trim();

return (
<Modal
Expand Down Expand Up @@ -87,7 +88,7 @@ function ChatRoomModalContent({
className="border-border-subtle flex shrink-0 items-center gap-8 border-t px-16 py-14 md:px-20"
onSubmit={(event) => {
event.preventDefault();
if (sendDisabled || !messageValue.trim()) return;
if (isSendDisabled) return;
onSendMessage?.();
}}
>
Expand Down Expand Up @@ -125,7 +126,7 @@ function ChatRoomModalContent({
"hover:bg-background-brand-hover disabled:bg-background-disabled transition-colors disabled:cursor-not-allowed",
"focus-visible:ring-border-brand focus-visible:ring-2 focus-visible:outline-none",
)}
disabled={sendDisabled || !messageValue.trim()}
disabled={isSendDisabled}
>
<Text variant="md-semibold" className="text-text-inverse">
전송
Expand Down
258 changes: 258 additions & 0 deletions src/components/chat/ChatRoomModalContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
"use client";

import ChatRoomCreateError from "@/components/chat/ChatRoomCreateError";
import ChatRoomModal from "@/components/chat/ChatRoomModal";
import Toast from "@/components/common/Toast/Toast";
import { Text } from "@/components/common/Text";
import {
useChatRoomModalController,
useConnectedChatRoomModalController,
} from "@/hooks/useChatRoomModalController";
import { cn } from "@/lib/utils/cn";
import type { ChatActionItem, ChatParticipantRole } from "@/components/chat/ChatActionSheet";
import type { ChatMessage, ChatRoom } from "@/types/chat";

interface ChatRoomModalContainerProps {
open: boolean;
estimateId: number;
participantRole: ChatParticipantRole;
participantName: string;
estimateSummary: string;
onClose: () => void;
actions?: Partial<Record<ChatActionItem["id"], Pick<ChatActionItem, "onSelect" | "disabled">>>;
}

interface ConnectedChatRoomModalProps extends Omit<ChatRoomModalContainerProps, "estimateId"> {
room: ChatRoom;
}

interface ChatMessageListProps {
messages: ChatMessage[];
participantRole: ChatParticipantRole;
isLoading: boolean;
isFetchingNextPage: boolean;
hasNextPage: boolean;
onFetchNextPage: () => void;
}

function formatMessageTime(createdAt: string): string {
const date = new Date(createdAt);

if (Number.isNaN(date.getTime())) {
return "";
}

return new Intl.DateTimeFormat("ko-KR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Asia/Seoul",
}).format(date);
}

function ChatMessageList({
messages,
participantRole,
isLoading,
isFetchingNextPage,
hasNextPage,
onFetchNextPage,
}: ChatMessageListProps) {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Text variant="lg-medium" className="text-text-muted">
대화 내역을 불러오는 중입니다.
</Text>
</div>
);
}

if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<Text variant="lg-medium" className="text-text-muted">
대화 내역이 없습니다.
</Text>
</div>
);
}

return (
<div className="flex min-h-full flex-col justify-end gap-12">
{hasNextPage ? (
<button
type="button"
className={cn(
"text-text-brand rounded-12 mx-auto px-12 py-8",
"hover:bg-background-brand-muted disabled:text-text-disabled",
"focus-visible:ring-border-brand focus-visible:ring-2 focus-visible:outline-none",
)}
disabled={isFetchingNextPage}
onClick={onFetchNextPage}
>
<Text variant="sm-semibold">
{isFetchingNextPage ? "이전 메시지 불러오는 중" : "이전 메시지 더보기"}
</Text>
</button>
) : null}

Comment thread
Obebe-creator marked this conversation as resolved.
{messages.map((message) => {
const isMine = message.sender.role === participantRole;

return (
<div
key={message.id}
className={cn("flex w-full flex-col gap-4", isMine ? "items-end" : "items-start")}
>
{!isMine ? (
<Text variant="sm-medium" className="text-text-muted">
{message.sender.name}
</Text>
) : null}
<div
className={cn(
"rounded-16 max-w-[78%] px-14 py-10",
isMine
? "bg-background-brand text-text-inverse rounded-br-4"
: "bg-background-subtle text-text-primary rounded-bl-4",
)}
>
<Text
as="p"
variant="md-medium"
className={cn(
"wrap-break-word whitespace-pre-wrap",
isMine ? "text-text-inverse" : "text-text-primary",
)}
>
{message.content}
</Text>
</div>
<Text variant="xs-medium" className="text-text-muted">
{formatMessageTime(message.createdAt)}
</Text>
</div>
);
})}
</div>
);
}

function ConnectedChatRoomModal({
open,
room,
participantRole,
participantName,
estimateSummary,
onClose,
actions,
}: ConnectedChatRoomModalProps) {
const chat = useConnectedChatRoomModalController({ open, room });

return (
<>
<ChatRoomModal
open={open}
participantRole={participantRole}
participantName={participantName}
estimateSummary={estimateSummary}
messageValue={chat.messageValue}
sendDisabled={chat.sendDisabled}
onMessageChange={chat.setMessageValue}
onSendMessage={() => void chat.handleSendMessage()}
onClose={onClose}
actions={actions}
>
{chat.isMessagesError ? (
<div className="flex h-full flex-col items-center justify-center gap-12">
<Text variant="lg-medium" className="text-text-muted">
{chat.messagesErrorMessage}
</Text>
<button
type="button"
className="text-text-brand rounded-12 px-12 py-8"
onClick={() => void chat.refetchMessages()}
>
<Text variant="sm-semibold">다시 시도</Text>
</button>
</div>
) : (
<ChatMessageList
messages={chat.messages}
participantRole={participantRole}
isLoading={chat.isMessagesPending}
isFetchingNextPage={chat.isFetchingNextPage}
hasNextPage={chat.hasNextPage}
onFetchNextPage={() => void chat.fetchNextPage()}
/>
)}
</ChatRoomModal>

{chat.toastMessage ? (
<Toast onClose={chat.clearToastMessage}>{chat.toastMessage}</Toast>
) : null}
</>
);
}

/**
* 채팅방 생성/조회, 메시지 목록, 소켓 연결을 묶는 채팅 모달 컨테이너
* // 2026.08.07 김성현 - [추가] 견적 상세 채팅 모달 BE 연동
*/
export default function ChatRoomModalContainer({
open,
estimateId,
participantRole,
participantName,
estimateSummary,
onClose,
actions,
}: ChatRoomModalContainerProps) {
const chatRoom = useChatRoomModalController({ open, estimateId });
const activeRoom = chatRoom.activeRoom;

if (!open) {
return null;
}

if (!activeRoom) {
return (
<ChatRoomModal
open={open}
participantRole={participantRole}
participantName={participantName}
estimateSummary={estimateSummary}
sendDisabled
onClose={onClose}
actions={actions}
>
{chatRoom.createErrorMessage ? (
<ChatRoomCreateError
message={chatRoom.createErrorMessage}
isRetrying={chatRoom.isChatRoomPending}
onRetry={chatRoom.retryCreateChatRoom}
/>
) : (
<div className="flex h-full items-center justify-center">
<Text variant="lg-medium" className="text-text-muted">
채팅방을 준비하는 중입니다.
</Text>
</div>
)}
</ChatRoomModal>
);
}
Comment thread
Obebe-creator marked this conversation as resolved.

return (
<ConnectedChatRoomModal
open={open}
room={activeRoom}
participantRole={participantRole}
participantName={participantName}
estimateSummary={estimateSummary}
onClose={onClose}
actions={actions}
/>
);
}
3 changes: 3 additions & 0 deletions src/components/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { default as ChatActionSheet } from "./ChatActionSheet";
export { default as ChatRoomCreateError } from "./ChatRoomCreateError";
export { default as ChatRoomModal } from "./ChatRoomModal";
export { default as ChatRoomModalContainer } from "./ChatRoomModalContainer";
export type { ChatActionItem, ChatParticipantRole } from "./ChatActionSheet";
export type { ChatRoomCreateErrorProps } from "./ChatRoomCreateError";
8 changes: 3 additions & 5 deletions src/components/estimate/pending/PendingEstimateDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from "react";

import ChatRoomModal from "@/components/chat/ChatRoomModal";
import ChatRoomModalContainer from "@/components/chat/ChatRoomModalContainer";
import Toast from "@/components/common/Toast/Toast";
import EstimateChatAction from "@/components/estimate/detail/EstimateChatAction";
import EstimateDetailActions from "@/components/estimate/detail/EstimateDetailActions";
Expand Down Expand Up @@ -41,7 +41,6 @@ interface PendingEstimateDetailContentProps {
function PendingEstimateDetailContent({ estimateId, data }: PendingEstimateDetailContentProps) {
const [confirmToastMessage, setConfirmToastMessage] = useState<string | null>(null);
const [isChatModalOpen, setIsChatModalOpen] = useState(false);
const [chatMessage, setChatMessage] = useState("");

const estimateRequestId = data.estimateRequest.id;
const canCancelRequest = isCancelableEstimateRequestStatus(data.estimateRequest.status);
Expand Down Expand Up @@ -107,13 +106,12 @@ function PendingEstimateDetailContent({ estimateId, data }: PendingEstimateDetai
}
/>

<ChatRoomModal
<ChatRoomModalContainer
open={isChatModalOpen}
estimateId={estimateId}
participantRole="CUSTOMER"
participantName={displayName}
estimateSummary={`견적가 - ${data.price.toLocaleString("ko-KR")}원`}
messageValue={chatMessage}
onMessageChange={setChatMessage}
onClose={() => setIsChatModalOpen(false)}
/>

Expand Down
Loading