Skip to content

Commit d9e36ff

Browse files
committed
Merge branch '89-feat/판매페이지-API-연동' of https://github.com/Leets-Official/LOOPIT-FE into 89-feat/판매페이지-API-연동
2 parents e241e26 + e6d4c34 commit d9e36ff

39 files changed

Lines changed: 1061 additions & 451 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"dependencies": {
2323
"@hookform/resolvers": "^5.2.2",
24+
"@stomp/stompjs": "^7.3.0",
2425
"@tanstack/react-query": "^5.90.12",
2526
"@tanstack/react-query-devtools": "^5.91.1",
2627
"@vercel/react-router": "^1.2.4",

src/app/layout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useLogoutMutation } from '@shared/apis/auth';
22
import { useAuth } from '@shared/apis/user';
3+
import { Footer } from '@shared/ui/Footer';
34
import { Header } from '@shared/ui/Header';
45
import { Outlet } from 'react-router';
56

@@ -22,6 +23,7 @@ const MainLayout = () => {
2223
<div className="mt-header-total">
2324
<Outlet />
2425
</div>
26+
<Footer />
2527
</div>
2628
);
2729
};

src/app/layout/ProtectedLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const ProtectedLayout = () => {
1010
}
1111

1212
if (!accessToken) {
13-
return <Navigate to={ROUTES.LOGIN} replace />;
13+
return <Navigate to={ROUTES.MAIN} replace />;
1414
}
1515

1616
return <Outlet />;

src/app/styles/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@
101101
}
102102

103103
@layer base {
104+
:root {
105+
--scrollbar-width: 0px;
106+
}
107+
104108
html {
105109
font-family: var(--font-sans);
106110
zoom: 0.9;

src/pages/chat/model/useChatState.ts

Lines changed: 130 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,169 @@
1-
import { CHAT_THREADS, INITIAL_STATUS_BY_THREAD, THREAD_CONTENT } from '@shared/mocks/data/chat';
2-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3-
import type { ThreadContent } from '@shared/types/chat';
4-
5-
type StatusOption = (typeof INITIAL_STATUS_BY_THREAD)[string];
6-
7-
const formatMetaLabel = (date: Date) => {
8-
const hours = date.getHours();
9-
const minutes = date.getMinutes();
10-
const meridiem = hours >= 12 ? '오후' : '오전';
11-
const hourLabel = `${hours % 12 === 0 ? 12 : hours % 12}`.padStart(2, '0');
12-
const minuteLabel = `${minutes}`.padStart(2, '0');
13-
return `읽음 · ${meridiem} ${hourLabel}:${minuteLabel}`;
14-
};
1+
import {
2+
type ChatMessageItem,
3+
type ChatRoomData,
4+
type PostStatus,
5+
type WebSocketMessage,
6+
chatSocket,
7+
isReadNotification,
8+
useChatMessagesQuery,
9+
useChatRoomsQuery,
10+
useCreateRoomMutation,
11+
} from '@shared/apis/chat';
12+
import { useAuthStore } from '@shared/stores';
13+
import { useCallback, useEffect, useRef, useState } from 'react';
1514

1615
export const useChatState = () => {
17-
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
18-
const [message, setMessage] = useState('');
19-
const [unreadByThread, setUnreadByThread] = useState<Record<string, boolean>>(() =>
20-
CHAT_THREADS.reduce<Record<string, boolean>>((acc, thread) => {
21-
if (thread.hasAlert) {
22-
acc[thread.id] = true;
23-
}
24-
return acc;
25-
}, {})
26-
);
27-
const [threadContent, setThreadContent] = useState<Record<string, ThreadContent>>(THREAD_CONTENT);
28-
const [statusByThread, setStatusByThread] = useState<Record<string, StatusOption>>(INITIAL_STATUS_BY_THREAD);
16+
const { userId } = useAuthStore();
17+
const [selectedRoomId, setSelectedRoomId] = useState<number | null>(null);
18+
const [currentRoom, setCurrentRoom] = useState<ChatRoomData | null>(null);
19+
const [localMessages, setLocalMessages] = useState<ChatMessageItem[]>([]);
20+
const [statusByRoom, setStatusByRoom] = useState<Record<number, PostStatus>>({});
2921
const messageListRef = useRef<HTMLDivElement | null>(null);
3022

31-
const hasSelection = selectedThreadId !== null;
32-
const activeThread = useMemo(
33-
() => (selectedThreadId ? threadContent[selectedThreadId] : null),
34-
[selectedThreadId, threadContent]
35-
);
23+
const { data: rooms = [], refetch: refetchRooms } = useChatRoomsQuery();
24+
const { data: serverMessages = [] } = useChatMessagesQuery(selectedRoomId);
25+
const { mutate: createRoom } = useCreateRoomMutation();
3626

37-
const markThreadReadIfNeeded = useCallback(
38-
(threadId: string | null) => {
39-
if (!threadId) {
40-
return;
41-
}
42-
if (!unreadByThread[threadId]) {
43-
return;
27+
const messages = [
28+
...serverMessages,
29+
...localMessages.filter((m) => !serverMessages.some((s) => s.messageId === m.messageId)),
30+
];
31+
32+
const hasSelection = selectedRoomId !== null;
33+
const activeStatus = selectedRoomId
34+
? (statusByRoom[selectedRoomId] ?? currentRoom?.postStatus ?? '판매중')
35+
: '판매중';
36+
37+
const handleWebSocketMessage = useCallback(
38+
(message: WebSocketMessage) => {
39+
if (isReadNotification(message)) {
40+
setLocalMessages((prev) => prev.map((m) => (m.roomId === message.roomId ? { ...m, read: true } : m)));
41+
} else {
42+
const newMessage: ChatMessageItem = {
43+
messageId: message.messageId,
44+
roomId: message.roomId,
45+
sellPostId: message.sellPostId,
46+
senderId: message.senderId,
47+
senderNickname: message.senderNickname,
48+
content: message.content,
49+
type: message.type,
50+
sendTime: message.sendTime,
51+
read: message.isRead,
52+
};
53+
setLocalMessages((prev) => [...prev, newMessage]);
54+
refetchRooms();
4455
}
45-
setUnreadByThread((prev) => ({ ...prev, [threadId]: false }));
4656
},
47-
[unreadByThread]
57+
[refetchRooms]
4858
);
4959

50-
const handleSend = (nextMessage: string) => {
51-
if (!selectedThreadId || !nextMessage.trim()) {
52-
setMessage('');
60+
// WebSocket 연결
61+
useEffect(() => {
62+
if (!userId) {
63+
return;
64+
}
65+
66+
chatSocket.connect(
67+
() => {
68+
if (selectedRoomId) {
69+
chatSocket.subscribeRoom(selectedRoomId, handleWebSocketMessage);
70+
}
71+
},
72+
(error) => {
73+
console.error('WebSocket error:', error);
74+
}
75+
);
76+
77+
return () => {
78+
chatSocket.disconnect();
79+
};
80+
}, [userId, selectedRoomId, handleWebSocketMessage]);
81+
82+
// 방 선택 시 구독 변경
83+
useEffect(() => {
84+
if (selectedRoomId && chatSocket.isConnected) {
85+
setLocalMessages([]);
86+
chatSocket.subscribeRoom(selectedRoomId, handleWebSocketMessage);
87+
chatSocket.sendRead({ roomId: selectedRoomId });
88+
}
89+
}, [selectedRoomId, handleWebSocketMessage]);
90+
91+
// 방 선택 시 상세 정보 조회
92+
useEffect(() => {
93+
if (!selectedRoomId || !userId) {
5394
return;
5495
}
5596

56-
const now = new Date();
57-
const metaLabel = formatMetaLabel(now);
97+
const room = rooms.find((r) => r.roomId === selectedRoomId);
98+
if (room) {
99+
createRoom(
100+
{ sellerId: room.partnerId, buyerId: userId, sellPostId: 0 },
101+
{
102+
onSuccess: (data) => {
103+
setCurrentRoom(data);
104+
setStatusByRoom((prev) => ({ ...prev, [selectedRoomId]: data.postStatus }));
105+
},
106+
}
107+
);
108+
}
109+
}, [selectedRoomId, userId, rooms, createRoom]);
58110

59-
setThreadContent((prev) => {
60-
const current = prev[selectedThreadId];
61-
if (!current) {
62-
return prev;
111+
const handleSend = useCallback(
112+
(content: string) => {
113+
if (!selectedRoomId || !content.trim()) {
114+
return;
63115
}
64-
return {
65-
...prev,
66-
[selectedThreadId]: {
67-
...current,
68-
timeline: [
69-
...current.timeline,
70-
{
71-
type: 'message',
72-
id: `local-${Date.now()}`,
73-
role: 'sender',
74-
message: nextMessage.trim(),
75-
meta: metaLabel,
76-
metaDateTime: now.toISOString(),
77-
},
78-
],
79-
},
80-
};
81-
});
82-
setMessage('');
83-
};
84116

85-
const scrollToBottom = () => {
117+
chatSocket.sendMessage({
118+
roomId: selectedRoomId,
119+
content: content.trim(),
120+
type: 'TEXT',
121+
});
122+
},
123+
[selectedRoomId]
124+
);
125+
126+
const scrollToBottom = useCallback(() => {
86127
const container = messageListRef.current;
87-
if (!container) {
88-
return;
128+
if (container) {
129+
container.scrollTop = container.scrollHeight;
89130
}
90-
container.scrollTop = container.scrollHeight;
91-
};
131+
}, []);
92132

93133
const handleScroll = useCallback(() => {
94-
if (!selectedThreadId) {
134+
if (!selectedRoomId) {
95135
return;
96136
}
137+
97138
const container = messageListRef.current;
98139
if (!container) {
99140
return;
100141
}
142+
101143
const isAtBottom =
102144
container.scrollHeight <= container.clientHeight + 1 ||
103145
container.scrollTop + container.clientHeight >= container.scrollHeight - 2;
146+
104147
if (isAtBottom) {
105-
markThreadReadIfNeeded(selectedThreadId);
148+
chatSocket.sendRead({ roomId: selectedRoomId });
106149
}
107-
}, [selectedThreadId, markThreadReadIfNeeded]);
150+
}, [selectedRoomId]);
108151

109152
useEffect(() => {
110-
if (!selectedThreadId) {
111-
return;
112-
}
113-
const handle = window.requestAnimationFrame(() => {
114-
scrollToBottom();
115-
handleScroll();
116-
});
117-
return () => window.cancelAnimationFrame(handle);
118-
}, [selectedThreadId, activeThread?.timeline.length, handleScroll]);
153+
scrollToBottom();
154+
}, [messages.length, scrollToBottom]);
119155

120156
return {
121-
activeThread,
157+
rooms,
158+
selectedRoomId,
159+
setSelectedRoomId,
160+
currentRoom,
161+
messages,
162+
currentUserId: userId ?? 0,
122163
hasSelection,
123-
message,
124164
messageListRef,
125-
selectedThreadId,
126-
setMessage,
127-
setSelectedThreadId,
128-
unreadByThread,
129-
statusByThread,
130-
setStatusByThread,
165+
activeStatus,
166+
setStatusByRoom,
131167
handleSend,
132168
handleScroll,
133169
};

0 commit comments

Comments
 (0)