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
10 changes: 7 additions & 3 deletions src/features/chat/api/chatList.ts
Original file line number Diff line number Diff line change
@@ -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()}일`;
};

Expand All @@ -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,
});
Expand Down
3 changes: 2 additions & 1 deletion src/features/chat/api/chatMessage.ts
Original file line number Diff line number Diff line change
@@ -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')}`;
Expand Down
10 changes: 5 additions & 5 deletions src/features/chat/api/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions src/features/chat/api/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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일 전' 등
};

Expand Down
10 changes: 9 additions & 1 deletion src/pages/chat/ChatRoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' 미판매
Expand All @@ -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(() => {
Expand Down Expand Up @@ -119,7 +127,7 @@ const ChatRoomPage = ({
/>
{room && (
<ChatProductBar
product={room.product}
product={{ ...room.product, price: barPrice }}
role={roleView}
saleStatus={saleStatusView}
tradeStatus={tradeStatusView}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/chat/component/ChatListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const ChatListItem = ({ room }: { room: ChatRoom }) => {
<p className="text-body-1 font-medium">{room.name}</p>
<p className="text-body-4 text-text-secondary truncate">{room.message}</p>
<p className="text-body-4 text-text-tertiary mt-0.5 truncate font-medium">
{room.product} · {room.date}
{room.product}
{room.date && ` · ${room.date}`}
</p>
</div>
{room.unread > 0 && (
Expand Down
5 changes: 3 additions & 2 deletions src/pages/chat/component/ChatMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
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()]}요일`;
};

// 같은 날짜인지 판별 (연·월·일 기준)
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[];
Expand Down
32 changes: 24 additions & 8 deletions src/pages/chat/component/ChatProductBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
>
Expand Down Expand Up @@ -94,20 +95,31 @@ 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 (
<div className="flex gap-2">
<Button
variant="solid"
size="36"
className="text-body-2 font-regular w-full! flex-1"
onClick={productId != null ? () => navigate(`/product/${productId}`) : undefined}
disabled={itemId == null}
onClick={itemId != null ? () => navigate(`/product?itemId=${itemId}`) : undefined}
>
상품 상세
</Button>
<Button
variant={right.filled ? 'filled' : 'solid'}
size="36"
className="text-body-2 font-regular w-full! flex-1"
disabled={rightDisabled}
onClick={
tradeStarted
? goTradeDetail
Expand Down Expand Up @@ -163,14 +175,18 @@ const ChatProductBar = ({
{saleStatus === 'unlisted' ? (
<>
<p className="text-body-3 text-text-secondary">미판매 상품</p>
<p className="text-body-3 text-text-secondary">
제안 금액 :{' '}
<span className="text-error font-semibold">{formatPrice(product.price)}</span>
</p>
{product.price != null && (
<p className="text-body-3 text-text-secondary">
제안 금액 :{' '}
<span className="text-error font-semibold">{formatPrice(product.price)}</span>
</p>
)}
</>
) : (
<>
<p className="text-body-2 text-error font-semibold">{formatPrice(product.price)}</p>
{product.price != null && (
<p className="text-body-2 text-error font-semibold">{formatPrice(product.price)}</p>
)}
<p className="text-body-3 text-text-secondary">{product.status}</p>
</>
)}
Expand Down
15 changes: 14 additions & 1 deletion src/pages/mypage/TradeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<p className="px-[16px] pt-[24px] pb-[8px] text-[12px] text-[#767676]">{children}</p>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="relative mx-auto min-h-dvh w-full max-w-[390px] bg-[#FFFFFF] font-sans">
<header className="sticky top-0 z-10 flex h-[52px] items-center justify-between border-b border-[#F0F0F0] bg-white px-[16px]">
Expand Down Expand Up @@ -291,7 +304,7 @@ const TradeDetailPage = () => {
</p>
</div>
</div>
<Button variant="ghost" size="36">
<Button variant="ghost" size="36" onClick={handleChat} disabled={isChatLoading}>
채팅하기
</Button>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/pages/purchase/OfferDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
11 changes: 9 additions & 2 deletions src/pages/purchase/components/ProposalDetailContent.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,6 +36,7 @@ export interface ProposalDetailContentProps {
status: string;
remainingSeconds: number;
itemId: number;
purchaseOfferId?: number; // 구매 제안 상세에서 채팅 열 때 함께 전송(소유자 경로)
itemNavigationPath: string;
brandName: string;
itemName: string;
Expand Down Expand Up @@ -136,6 +137,7 @@ const ProposalDetailContent = ({
status,
remainingSeconds: initialRemaining,
itemId,
purchaseOfferId,
itemNavigationPath,
brandName,
itemName,
Expand All @@ -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<ReactNode>(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);
}
Expand Down Expand Up @@ -450,6 +456,7 @@ const ProposalDetailContent = ({
onAccept();
}}
/>
<Toast message={toast} onClose={() => setToast(null)} />
</div>
);
};
Expand Down
5 changes: 5 additions & 0 deletions src/shared/lib/parseServerDate.ts
Original file line number Diff line number Diff line change
@@ -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`);
Loading