From dc6cedcca2c5b1b7d7219bb98cfe6d66464ce636 Mon Sep 17 00:00:00 2001 From: lgdl24 Date: Thu, 13 Aug 2026 19:54:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[fix/#243]=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD,?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EB=8D=95=ED=8A=B8=EB=B0=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/chat/api/chatList.ts | 10 ++++-- src/features/chat/api/chatMessage.ts | 3 +- src/features/chat/api/dto.ts | 10 +++--- src/features/chat/api/room.ts | 14 +++++--- src/features/chat/model/types.ts | 2 +- src/pages/chat/ChatRoomPage.tsx | 10 +++++- src/pages/chat/component/ChatListItem.tsx | 3 +- src/pages/chat/component/ChatMessages.tsx | 5 +-- src/pages/chat/component/ChatProductBar.tsx | 32 ++++++++++++++----- src/pages/purchase/OfferDetailPage.tsx | 1 + .../components/ProposalDetailContent.tsx | 11 +++++-- src/shared/lib/parseServerDate.ts | 5 +++ 12 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 src/shared/lib/parseServerDate.ts diff --git a/src/features/chat/api/chatList.ts b/src/features/chat/api/chatList.ts index 0b28e29..f068157 100644 --- a/src/features/chat/api/chatList.ts +++ b/src/features/chat/api/chatList.ts @@ -1,10 +1,14 @@ import { api } from '@/shared/lib/api'; import { resolveFileUrl } from '@/shared/lib/resolveFileUrl'; +import { parseServerDate } from '@/shared/lib/parseServerDate'; import type { ChatRoomFilterType, ChatRoomListResponse, ChatRoomSummary } from './dto'; import type { ChatRoom } from '../model/types'; -const formatChatDate = (iso: string): string => { - const d = new Date(iso); +// 메시지 없는 방은 lastMessageAt이 null → 날짜 미표시(빈 문자열). 값 있으면 타임존 보정 후 표기. +const formatChatDate = (iso: string | null | undefined): string => { + if (!iso) return ''; + const d = parseServerDate(iso); + if (Number.isNaN(d.getTime())) return ''; return `${d.getMonth() + 1}월 ${d.getDate()}일`; }; @@ -13,7 +17,7 @@ const toChatRoom = (s: ChatRoomSummary): ChatRoom => ({ name: s.opponentUsername, avatar: resolveFileUrl(s.opponentProfileImgUrl), product: `${s.brandName} / ${s.itemName}`, - message: s.lastMessage, + message: s.lastMessage ?? '', date: formatChatDate(s.lastMessageAt), unread: s.unreadCount, }); diff --git a/src/features/chat/api/chatMessage.ts b/src/features/chat/api/chatMessage.ts index 45203e2..09311a9 100644 --- a/src/features/chat/api/chatMessage.ts +++ b/src/features/chat/api/chatMessage.ts @@ -1,10 +1,11 @@ import { USER_ID_STORAGE_KEY } from '@/shared/lib/api'; import { resolveFileUrl } from '@/shared/lib/resolveFileUrl'; +import { parseServerDate } from '@/shared/lib/parseServerDate'; import type { ChatMessageResponse } from './dto'; import type { ChatMessage } from '../model/types'; const formatTime = (iso: string): string => { - const d = new Date(iso); + const d = parseServerDate(iso); const ampm = d.getHours() < 12 ? '오전' : '오후'; const hh = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12; return `${ampm} ${hh}:${d.getMinutes().toString().padStart(2, '0')}`; diff --git a/src/features/chat/api/dto.ts b/src/features/chat/api/dto.ts index 20f0ac5..c94206f 100644 --- a/src/features/chat/api/dto.ts +++ b/src/features/chat/api/dto.ts @@ -7,8 +7,8 @@ export interface ChatRoomSummary { opponentProfileImgUrl: string | null; brandName: string; itemName: string; - lastMessage: string; - lastMessageAt: string; + lastMessage: string | null; // 메시지 없으면 null + lastMessageAt: string | null; // 메시지 없으면 null unreadCount: number; } @@ -58,11 +58,11 @@ export interface ChatRoomDetailResponse { itemImageUrl: string | null; brandName: string; itemName: string; - productStatus: ProductStatus; - price: number; + productStatus: ProductStatus | null; // 상품 row 없으면(미등록 제안) null + price: number | null; // 상품 없으면 null → 제안금액으로 대체 표시 lastWornAt: string | null; tradeId: number | null; - productId: number; + productId: number | null; // 상품 row 없으면 null itemId: number; buyer: boolean; purchaseIntentId: number | null; // SENT 구매 의사 ID, 없으면 null diff --git a/src/features/chat/api/room.ts b/src/features/chat/api/room.ts index de86bbe..21dbfd4 100644 --- a/src/features/chat/api/room.ts +++ b/src/features/chat/api/room.ts @@ -3,9 +3,13 @@ import { resolveFileUrl } from '@/shared/lib/resolveFileUrl'; import type { ChatProduct, ChatRole, SaleStatus, TradeStatus } from '../model/types'; import type { ChatRoomDetailResponse, ProductStatus } from './dto'; -// 채팅방 생성 또는 조회 (아이템 상세 페이지 채팅하기 버튼) -export const createOrGetChatRoom = (itemId: number) => - api.post<{ chatRoomId: number }>('/chats', { itemId }); +// 채팅방 생성 또는 조회 (아이템 상세 페이지 채팅하기 버튼). +// 판매자가 받은 구매 제안 상세에서 열 땐 purchaseOfferId를 함께 보내 제안자↔소유자 방을 잡는다. +export const createOrGetChatRoom = (itemId: number, purchaseOfferId?: number) => + api.post<{ chatRoomId: number }>('/chats', { + itemId, + ...(purchaseOfferId != null && { purchaseOfferId }), + }); // 채팅방 상세 조회 (프로덕트 바 데이터) export const getChatRoom = (chatRoomId: number) => { @@ -27,7 +31,7 @@ export interface ChatRoomView { tradeStatus: TradeStatus; opponentName: string; opponentAvatar: string; - productId: number; + productId: number | null; itemId: number; tradeId: number | null; purchaseIntentId: number | null; @@ -40,7 +44,7 @@ export const toChatRoomView = (r: ChatRoomDetailResponse): ChatRoomView => ({ thumbnail: resolveFileUrl(r.itemImageUrl), brand: `${r.brandName} / ${r.itemName}`, price: r.price, - status: PRODUCT_STATUS_LABEL[r.productStatus], + status: r.productStatus ? PRODUCT_STATUS_LABEL[r.productStatus] : '', }, role: r.buyer ? 'buyer' : 'seller', saleStatus: r.productStatus === 'ON_SALE' ? 'onSale' : 'unlisted', diff --git a/src/features/chat/model/types.ts b/src/features/chat/model/types.ts index 6d59797..3b4563e 100644 --- a/src/features/chat/model/types.ts +++ b/src/features/chat/model/types.ts @@ -21,7 +21,7 @@ export type TradeStatus = 'none' | 'waiting' | 'created' | 'done'; export type ChatProduct = { thumbnail: string; brand: string; - price: number; + price: number | null; // 상품 없음/제안금액 미확보 시 null → 가격 줄 숨김 status: string; // '판매 중 · 최근 착용 1일 전' 등 }; diff --git a/src/pages/chat/ChatRoomPage.tsx b/src/pages/chat/ChatRoomPage.tsx index 3566c2c..bc47b28 100644 --- a/src/pages/chat/ChatRoomPage.tsx +++ b/src/pages/chat/ChatRoomPage.tsx @@ -16,6 +16,7 @@ import { useChatMessages } from '@/features/chat/api/useChatMessages'; import { uploadChatImages } from '@/features/chat/api/messages'; import { useMarkChatRead } from '@/features/chat/api/useMarkChatRead'; import { useChatRoom } from '@/features/chat/api/useChatRoom'; +import { useOfferDetail } from '@/features/purchase/model/useOfferDetail'; // role: 'buyer' | 'seller' (내 입장) // saleStatus: 'onSale' 판매중 / 'unlisted' 미판매 @@ -40,6 +41,13 @@ const ChatRoomPage = ({ const { data: history, fetchNextPage, hasNextPage, isFetchingNextPage } = useChatMessages(roomId); const { mutate: markRead } = useMarkChatRead(); const { data: room } = useChatRoom(roomId); + // 가격은 매핑된 room.product.price에 들어있다(ON_SALE 등은 상품가). 상품 row가 없어 + // price가 null인데 구매 제안이 있으면, 제안 상세에서 제안금액을 가져와 대체 표시. + // (offerId 0이면 useOfferDetail이 disabled → 불필요한 호출 안 함) + const productPrice = room?.product.price ?? null; + const offerIdForPrice = productPrice == null ? (room?.purchaseOfferId ?? 0) : 0; + const { data: offer } = useOfferDetail(offerIdForPrice); + const barPrice = productPrice ?? offer?.amounts.offerAmount ?? null; // 방 진입 시 읽음 처리 (내 unreadCount 0 → 목록 뱃지 제거) useEffect(() => { @@ -119,7 +127,7 @@ const ChatRoomPage = ({ /> {room && ( {

{room.name}

{room.message}

- {room.product} · {room.date} + {room.product} + {room.date && ` · ${room.date}`}

{room.unread > 0 && ( diff --git a/src/pages/chat/component/ChatMessages.tsx b/src/pages/chat/component/ChatMessages.tsx index 6f3533d..0734fd5 100644 --- a/src/pages/chat/component/ChatMessages.tsx +++ b/src/pages/chat/component/ChatMessages.tsx @@ -1,12 +1,13 @@ import { Fragment, useEffect, useRef } from 'react'; import type { ChatMessage } from '@/features/chat/model/types'; +import { parseServerDate } from '@/shared/lib/parseServerDate'; import MessageBubble from './MessageBubble'; const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; // ISO → '2026.06.21 월요일' const formatDateLabel = (iso: string): string => { - const d = new Date(iso); + const d = parseServerDate(iso); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${d.getFullYear()}.${mm}.${dd} ${WEEKDAYS[d.getDay()]}요일`; @@ -14,7 +15,7 @@ const formatDateLabel = (iso: string): string => { // 같은 날짜인지 판별 (연·월·일 기준) const sameDay = (a: string, b: string): boolean => - new Date(a).toDateString() === new Date(b).toDateString(); + parseServerDate(a).toDateString() === parseServerDate(b).toDateString(); type Props = { messages: ChatMessage[]; diff --git a/src/pages/chat/component/ChatProductBar.tsx b/src/pages/chat/component/ChatProductBar.tsx index c4e0e0f..3c83b35 100644 --- a/src/pages/chat/component/ChatProductBar.tsx +++ b/src/pages/chat/component/ChatProductBar.tsx @@ -51,13 +51,14 @@ const ChatActions = ({ variant="solid" size="36" className="text-body-2 font-regular w-full! flex-1" + disabled={itemId == null} onClick={ isUnlisted ? itemId != null ? () => navigate(`/items/${itemId}`) : undefined - : productId != null - ? () => navigate(`/product/${productId}`) + : itemId != null + ? () => navigate(`/product?itemId=${itemId}`) : undefined } > @@ -94,13 +95,23 @@ const ChatActions = ({ saleStatus === 'onSale' ? { label: '구매하기', filled: true } : { label: '보낸 요청 보기' }; } + // 이동 대상(ID/파라미터)이 없으면 버튼을 비활성화해 '꺼진' 상태로 보이게 한다. + const rightDisabled = tradeStarted + ? tradeId == null + : right.label === '구매하기' + ? productId == null + : right.label === '보낸 요청 보기' + ? purchaseOfferId == null && purchaseIntentId == null + : true; + return (
@@ -108,6 +119,7 @@ const ChatActions = ({ variant={right.filled ? 'filled' : 'solid'} size="36" className="text-body-2 font-regular w-full! flex-1" + disabled={rightDisabled} onClick={ tradeStarted ? goTradeDetail @@ -163,14 +175,18 @@ const ChatProductBar = ({ {saleStatus === 'unlisted' ? ( <>

미판매 상품

-

- 제안 금액 :{' '} - {formatPrice(product.price)} -

+ {product.price != null && ( +

+ 제안 금액 :{' '} + {formatPrice(product.price)} +

+ )} ) : ( <> -

{formatPrice(product.price)}

+ {product.price != null && ( +

{formatPrice(product.price)}

+ )}

{product.status}

)} diff --git a/src/pages/purchase/OfferDetailPage.tsx b/src/pages/purchase/OfferDetailPage.tsx index b9df6bc..e4d27dd 100644 --- a/src/pages/purchase/OfferDetailPage.tsx +++ b/src/pages/purchase/OfferDetailPage.tsx @@ -70,6 +70,7 @@ const OfferDetailPage = () => { status={data.status} remainingSeconds={data.remainingSeconds} itemId={data.item.itemId} + purchaseOfferId={offerIdNum} itemNavigationPath={`/items/${data.item.itemId}`} brandName={data.item.brandName} itemName={data.item.itemName} diff --git a/src/pages/purchase/components/ProposalDetailContent.tsx b/src/pages/purchase/components/ProposalDetailContent.tsx index 6b4b6a3..ceb32cd 100644 --- a/src/pages/purchase/components/ProposalDetailContent.tsx +++ b/src/pages/purchase/components/ProposalDetailContent.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { ChevronRight, Clock } from 'lucide-react'; -import { BackHeader, Button, DoubleButton } from '@/shared/components'; +import { BackHeader, Button, DoubleButton, Toast } from '@/shared/components'; import { cn } from '@/shared/lib/cn'; import { resolveImageUrl } from '@/shared/lib/resolveImageUrl'; import { createOrGetChatRoom } from '@/features/chat/api/room'; @@ -36,6 +36,7 @@ export interface ProposalDetailContentProps { status: string; remainingSeconds: number; itemId: number; + purchaseOfferId?: number; // 구매 제안 상세에서 채팅 열 때 함께 전송(소유자 경로) itemNavigationPath: string; brandName: string; itemName: string; @@ -136,6 +137,7 @@ const ProposalDetailContent = ({ status, remainingSeconds: initialRemaining, itemId, + purchaseOfferId, itemNavigationPath, brandName, itemName, @@ -156,13 +158,17 @@ const ProposalDetailContent = ({ const [remaining, setRemaining] = useState(initialRemaining); const [dialog, setDialog] = useState<'accept' | 'reject' | 'cancel' | null>(null); const [isChatLoading, setIsChatLoading] = useState(false); + const [toast, setToast] = useState(null); const handleChat = async () => { if (isChatLoading) return; setIsChatLoading(true); try { - const { chatRoomId } = await createOrGetChatRoom(itemId); + const { chatRoomId } = await createOrGetChatRoom(itemId, purchaseOfferId); navigate(`/chat/${chatRoomId}`); + } catch { + // 방 생성 실패(권한/상태 등) 시 uncaught 크래시 방지 + 사용자 안내 + setToast('채팅방을 열 수 없습니다.'); } finally { setIsChatLoading(false); } @@ -450,6 +456,7 @@ const ProposalDetailContent = ({ onAccept(); }} /> + setToast(null)} />
); }; diff --git a/src/shared/lib/parseServerDate.ts b/src/shared/lib/parseServerDate.ts new file mode 100644 index 0000000..6a6c521 --- /dev/null +++ b/src/shared/lib/parseServerDate.ts @@ -0,0 +1,5 @@ +// 서버가 시각을 타임존 표기(Z/offset) 없이 UTC로 내려주는 경우가 있다. +// 그대로 new Date()에 넣으면 JS가 '로컬 시각'으로 오해해 KST(+9) 변환이 누락된다. +// → offset이 없으면 UTC로 간주해 Z를 붙여 파싱한다. (이미 Z/offset 있으면 그대로) +export const parseServerDate = (iso: string): Date => + new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`); From 0afb217918a268c8c71d16aeb249b06280117a97 Mon Sep 17 00:00:00 2001 From: yjudy0531 Date: Thu, 13 Aug 2026 20:03:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20TradeDetailPage=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProposalDetailContent.tsx의 handleChat 패턴을 참고하여 createOrGetChatRoom 호출 후 채팅방으로 이동하도록 연결 미검증(로컬 CORS 이슈로 테스트 불가) - PR 리뷰 시 실제 동작 확인 필요 Co-Authored-By: Claude Sonnet 5 --- src/pages/mypage/TradeDetailPage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pages/mypage/TradeDetailPage.tsx b/src/pages/mypage/TradeDetailPage.tsx index b0037e0..ba83116 100644 --- a/src/pages/mypage/TradeDetailPage.tsx +++ b/src/pages/mypage/TradeDetailPage.tsx @@ -8,6 +8,7 @@ import { useTradeStatusChange } from '@/features/trade/api/useTradeStatusChange' import type { TradeStatus } from '@/features/trade/api/types'; import { buildTradeTimeline } from '@/features/trade/lib/buildTradeTimeline'; import { resolveImageUrl } from '@/shared/lib/resolveImageUrl'; +import { createOrGetChatRoom } from '@/features/chat/api/room'; const SectionTitle = ({ children }: { children: ReactNode }) => (

{children}

@@ -68,6 +69,7 @@ const TradeDetailPage = () => { Boolean((location.state as { shippingConfirmed?: boolean } | null)?.shippingConfirmed), ); const shippingToastHandledRef = useRef(false); + const [isChatLoading, setIsChatLoading] = useState(false); useEffect(() => { if (!showShippingToast || shippingToastHandledRef.current) return; @@ -146,6 +148,17 @@ const TradeDetailPage = () => { ); }; + const handleChat = async () => { + if (isChatLoading) return; + setIsChatLoading(true); + try { + const { chatRoomId } = await createOrGetChatRoom(trade.itemId); + navigate(`/chat/${chatRoomId}`); + } finally { + setIsChatLoading(false); + } + }; + return (
@@ -291,7 +304,7 @@ const TradeDetailPage = () => {

-