Skip to content
29 changes: 29 additions & 0 deletions src/app/chats/[roomId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import ChatRoomPageClient from "@/components/chat/ChatRoomPageClient";
import { parsePositiveIntId } from "@/lib/utils/parsePositiveIntId";

export const metadata: Metadata = {
title: "채팅방",
description: "이사 견적 채팅방입니다.",
};

interface ChatRoomPageProps {
params: Promise<{ roomId: string }>;
}

/**
* 채팅 알림 클릭 시 roomId로 진입하는 페이지
* // 2026.08.12 김성현 - [추가] 채팅방 상세 라우트
*/
export default async function ChatRoomPage({ params }: ChatRoomPageProps) {
const { roomId } = await params;
const id = parsePositiveIntId(roomId);

if (id === null) {
notFound();
}

return <ChatRoomPageClient roomId={id} />;
}
9 changes: 6 additions & 3 deletions src/components/chat/ChatRoomModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { ChatActionItem, ChatParticipantRole } from "@/components/chat/Chat
import type { ChatEstimateEditConfig } from "@/components/chat/ChatRoomModal";
import type { ChatMessage, ChatRoom } from "@/types/chat";

interface ChatRoomModalContainerProps {
export interface ChatRoomModalContainerProps {
open: boolean;
estimateId: number;
participantRole: ChatParticipantRole;
Expand All @@ -26,7 +26,10 @@ interface ChatRoomModalContainerProps {
estimateEdit?: ChatEstimateEditConfig;
}

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

Expand Down Expand Up @@ -184,7 +187,7 @@ function ChatMessageList({
);
}

function ConnectedChatRoomModal({
export function ConnectedChatRoomModal({
open,
room,
participantRole,
Expand Down
154 changes: 154 additions & 0 deletions src/components/chat/ChatRoomPageClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useRouter } from "next/navigation";

import { ConnectedChatRoomModal } from "@/components/chat/ChatRoomModalContainer";
import { Text } from "@/components/common/Text";
import { useChatRoom } from "@/hooks/useChatRoom";
import { getAccessTokenPayload } from "@/lib/auth/accessTokenPayload";
import { getAccessToken } from "@/lib/auth/token";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { cn } from "@/lib/utils/cn";
import { useAuthStore } from "@/stores/useAuthStore";
import type { ChatParticipantRole } from "@/components/chat/ChatActionSheet";
import type { ChatRoom } from "@/types/chat";

interface ChatRoomPageClientProps {
roomId: number;
}

interface ChatRoomViewMeta {
participantRole: ChatParticipantRole;
participantName: string;
}

function resolveCurrentUserId(isAuthenticated: boolean, storeUserId?: string): string | null {
if (storeUserId) {
return storeUserId;
}

if (!isAuthenticated) {
return null;
}

return getAccessTokenPayload(getAccessToken() ?? "").userId ?? null;
}

function resolveChatRoomViewMeta(room: ChatRoom, currentUserId: string): ChatRoomViewMeta | null {
if (room.customer.id === currentUserId) {
return {
participantRole: "CUSTOMER",
participantName: room.mover.name,
};
}

if (room.mover.id === currentUserId) {
return {
participantRole: "MOVER",
participantName: room.customer.name,
};
}

return null;
}

function ChatRoomPageState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<main className="bg-background-surface flex min-h-[calc(100dvh-88px)] items-center justify-center px-24">
<div className="flex flex-col items-center gap-16 text-center">
<Text as="h1" variant="lg-semibold" className="text-text-primary">
{message}
</Text>

{actionLabel && onAction ? (
<button
type="button"
className={cn(
"border-border-brand text-text-brand rounded-12 border px-20 py-12",
"hover:bg-background-brand-muted focus-visible:ring-border-brand focus-visible:ring-2 focus-visible:outline-none",
)}
onClick={onAction}
>
<Text variant="md-semibold">{actionLabel}</Text>
</button>
) : null}
</div>
</main>
);
}

/**
* 알림 linkUrl(/chats/:roomId)로 진입하는 채팅방 페이지
* // 2026.08.12 김성현 - [추가] roomId 기반 채팅방 알림 진입 화면
*/
export default function ChatRoomPageClient({ roomId }: ChatRoomPageClientProps) {
const router = useRouter();
const hasHydrated = useAuthStore((state) => state.hasHydrated);
const isCheckingAuth = useAuthStore((state) => state.isCheckingAuth);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const storeUserId = useAuthStore((state) => state.user?.id);
const currentUserId = resolveCurrentUserId(isAuthenticated, storeUserId);
const canLoadRoom = roomId > 0 && hasHydrated && isAuthenticated;
const chatRoom = useChatRoom(roomId, canLoadRoom);
const room = chatRoom.data;

const handleClose = () => {
if (window.history.length > 1) {
router.back();
return;
}

router.push(APP_ROUTES.HOME);
};
Comment thread
Obebe-creator marked this conversation as resolved.

if (!hasHydrated || isCheckingAuth) {
return <ChatRoomPageState message="채팅방을 불러오는 중입니다." />;
}

if (!isAuthenticated) {
return <ChatRoomPageState message="로그인이 필요한 채팅방입니다." />;
}

if (chatRoom.isPending) {
return <ChatRoomPageState message="채팅방을 불러오는 중입니다." />;
}

if (chatRoom.isError || !room) {
return (
<ChatRoomPageState
message="채팅방을 불러오지 못했습니다."
actionLabel="다시 시도"
onAction={() => void chatRoom.refetch()}
/>
);
}

if (!currentUserId) {
return <ChatRoomPageState message="사용자 정보를 확인하는 중입니다." />;
}

const viewMeta = resolveChatRoomViewMeta(room, currentUserId);

if (!viewMeta) {
return <ChatRoomPageState message="이 채팅방에 접근할 수 없습니다." />;
}

return (
<ConnectedChatRoomModal
open
room={room}
participantRole={viewMeta.participantRole}
participantName={viewMeta.participantName}
estimateSummary={`견적 #${room.estimateId}`}
onClose={handleClose}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const NOTIFICATION_MESSAGE_TEMPLATES: Record<NotificationType, Notificati
//메세지 도착 알림 post 아직 없음
CHAT_MESSAGE_RECEIVED: {
prefix: "",
suffix: " 새 메시지가 도착했어요",
suffix: "님으로부터 메시지가 도착했습니다.",
Comment thread
Obebe-creator marked this conversation as resolved.
},
//공지사항 추가 알림 문구
NOTICE_RECEIVED: {
Expand Down
12 changes: 10 additions & 2 deletions src/components/estimate/pending/PendingEstimateDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import EstimateDetailLayout, {
import EstimateDetailPrice from "@/components/estimate/detail/EstimateDetailPrice";
import EstimateRequestCancelConfirmModal from "@/components/estimate/requests/EstimateRequestCancelConfirmModal";
import { useConfirmEstimate } from "@/hooks/useConfirmEstimate";
import { useChatModalSearchParam } from "@/hooks/useChatModalSearchParam";
import { useEstimateDetail } from "@/hooks/useEstimateDetail";
import { useEstimateRequestCancelFlow } from "@/hooks/useEstimateRequestCancelFlow";
import { getApiErrorMessage } from "@/lib/api/getApiErrorMessage";
Expand All @@ -44,11 +45,18 @@ function PendingEstimateDetailContent({
}: PendingEstimateDetailContentProps) {
const [confirmToastMessage, setConfirmToastMessage] = useState<string | null>(null);
const [isChatModalOpen, setIsChatModalOpen] = useState(false);
const { isChatOpenRequested, clearChatOpenSearchParam } = useChatModalSearchParam();

const estimateRequestId = data.estimateRequest.id;
const canCancelRequest = isCancelableEstimateRequestStatus(data.estimateRequest.status);
const displayName = data.mover.nickname || data.mover.name;
const showChatAction = data.status === "SENT";
const isChatOpen = isChatModalOpen || (showChatAction && isChatOpenRequested);

@wndnjs2037 wndnjs2037 Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

견적 status가 SENT가 아니면 showChatAction이 false라 모달이 안 열리는데, 파라미터를 지우는 코드가 모달의 onClose 안에만 있어서 chat=open이 URL에 그대로 남아서 견적이 이미 확정으로 바뀐 뒤에 알림을 누른 경우처럼, 알림을 눌러 들어왔는데 아무 반응도 안내도 없는 상태가 될 수 있을 것 같습니다.
한번 체크해보시면 좋을 것 같아요!


const handleCloseChatModal = () => {
setIsChatModalOpen(false);
clearChatOpenSearchParam();
};

const confirmMutation = useConfirmEstimate(estimateId, {
onSuccess: () => setConfirmToastMessage("견적이 확정되었어요."),
Expand Down Expand Up @@ -111,12 +119,12 @@ function PendingEstimateDetailContent({
/>

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

<EstimateRequestCancelConfirmModal
Expand Down
12 changes: 10 additions & 2 deletions src/components/estimate/sent/SentEstimateDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import SentEstimateChatAction from "@/components/estimate/sent/SentEstimateChatA
import SentEstimateCompleteAction from "@/components/estimate/sent/SentEstimateCompleteAction";
import { MoveTypeChip } from "@/components/common/Chip/MoveTypeChip";
import DesignatedChip from "@/components/estimate/DesignatedChip";
import { useChatModalSearchParam } from "@/hooks/useChatModalSearchParam";
import { useSentEstimateDetail } from "@/hooks/useSentEstimates";
import FrameIcon from "@/icons/frame.svg";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
Expand Down Expand Up @@ -112,6 +113,7 @@ function SentEstimateComment({ comment }: { comment: string }) {

export default function SentEstimateDetailPage({ estimateId }: SentEstimateDetailPageProps) {
const [isChatModalOpen, setIsChatModalOpen] = useState(false);
const { isChatOpenRequested, clearChatOpenSearchParam } = useChatModalSearchParam();
const query = useSentEstimateDetail(estimateId);

if (query.isPending) {
Expand All @@ -136,6 +138,12 @@ export default function SentEstimateDetailPage({ estimateId }: SentEstimateDetai
const showChatAction = estimate.status === "SENT";
const showCompleteAction =
estimate.status === "CONFIRMED" && isKstDateOnOrAfter(request.moveDate);
const isChatOpen = isChatModalOpen || (showChatAction && isChatOpenRequested);

const handleCloseChatModal = () => {
setIsChatModalOpen(false);
clearChatOpenSearchParam();
};

return (
<>
Expand Down Expand Up @@ -204,7 +212,7 @@ export default function SentEstimateDetailPage({ estimateId }: SentEstimateDetai
/>

<ChatRoomModalContainer
open={isChatModalOpen}
open={isChatOpen}
estimateId={estimate.id}
participantRole="MOVER"
participantName={estimate.customer.name}
Expand All @@ -213,7 +221,7 @@ export default function SentEstimateDetailPage({ estimateId }: SentEstimateDetai
moveDateLabel: formatKoreanDateTime(request.moveDate),
priceLabel: formatPrice(estimate.price),
}}
onClose={() => setIsChatModalOpen(false)}
onClose={handleCloseChatModal}
/>
</>
);
Expand Down
35 changes: 35 additions & 0 deletions src/hooks/useChatModalSearchParam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";

const CHAT_MODAL_SEARCH_PARAM = "chat";
const CHAT_MODAL_SEARCH_VALUE = "open";

/**
* 견적 상세 페이지 진입 시 채팅 모달 자동 open query를 처리합니다.
* // 2026.08.13 김성현 - [추가] 알림 클릭 후 견적 상세에서 채팅 모달 자동 열림 처리
*/
export function useChatModalSearchParam() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isChatOpenRequested = searchParams.get(CHAT_MODAL_SEARCH_PARAM) === CHAT_MODAL_SEARCH_VALUE;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const clearChatOpenSearchParam = useCallback(() => {
if (!searchParams.has(CHAT_MODAL_SEARCH_PARAM)) {
return;
}

const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete(CHAT_MODAL_SEARCH_PARAM);
const nextSearch = nextSearchParams.toString();

router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);

return {
isChatOpenRequested,
clearChatOpenSearchParam,
};
}
7 changes: 7 additions & 0 deletions src/hooks/useChatRoomSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ChatMessage,
JoinChatRoomAck,
JoinChatRoomPayload,
LeaveChatRoomPayload,
SendChatMessageAck,
SendChatMessagePayload,
} from "@/types/chat";
Expand Down Expand Up @@ -86,6 +87,12 @@ export function useChatRoomSocket({
onJoinErrorRef.current?.(response.error);
}
});

return () => {
const leavePayload: LeaveChatRoomPayload = { roomId };

socket.emit("chat:room:leave", leavePayload);
};
}, [canConnect, isConnected, roomId, socket]);

const sendMessage = useCallback(
Expand Down
13 changes: 13 additions & 0 deletions src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export interface JoinChatRoomPayload {
lastMessageId?: number | null;
}

export interface LeaveChatRoomPayload {
roomId: number;
}

export interface ChatSocketError {
code: string;
message: string;
Expand All @@ -74,6 +78,15 @@ export type JoinChatRoomAck =
error: ChatSocketError;
};

export type LeaveChatRoomAck =
| {
ok: true;
}
| {
ok: false;
error: ChatSocketError;
};

export type SendChatMessageAck =
| {
ok: true;
Expand Down