Skip to content

Commit 2ea1f1a

Browse files
committed
Merge branch '83-feat/Footer' of https://github.com/Leets-Official/LOOPIT-FE into 83-feat/Footer
2 parents 4e52960 + b45d966 commit 2ea1f1a

38 files changed

Lines changed: 1051 additions & 124 deletions

src/app/layout/ProtectedLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { ROUTES } from '@shared/constants';
12
import { useAuthStore } from '@shared/stores';
23
import { Navigate, Outlet } from 'react-router';
34

45
const ProtectedLayout = () => {
5-
const accessToken = useAuthStore((state) => state.accessToken);
6+
const { accessToken, _hasHydrated } = useAuthStore();
7+
8+
if (!_hasHydrated) {
9+
return null;
10+
}
611

712
if (!accessToken) {
8-
return <Navigate to="/login" replace />;
13+
return <Navigate to={ROUTES.LOGIN} replace />;
914
}
1015

1116
return <Outlet />;

src/app/layout/PublicLayout.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { ROUTES } from '@shared/constants';
12
import { useAuthStore } from '@shared/stores';
23
import { Navigate, Outlet } from 'react-router';
34

45
const PublicLayout = () => {
5-
const accessToken = useAuthStore((state) => state.accessToken);
6+
const { accessToken, _hasHydrated } = useAuthStore();
7+
8+
if (!_hasHydrated) {
9+
return null;
10+
}
611

712
if (accessToken) {
8-
return <Navigate to="/" replace />;
13+
return <Navigate to={ROUTES.MAIN} replace />;
914
}
1015

1116
return <Outlet />;

src/app/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export default [
3636
route('mypage/settings', 'routes/(main)/mypage/settings.tsx'),
3737
route('mypage/profile', 'routes/(main)/mypage/profile.tsx'),
3838

39+
// chat
40+
route('chat', 'routes/(main)/chat/index.tsx'),
41+
3942
// chatbot
4043
route('chatbot', 'routes/(main)/chatbot/index.tsx'),
4144
]),
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { ChatPage } from '@pages/chat';
2+
3+
export default ChatPage;

src/app/styles/index.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
--header-height: 92px;
9898
--spacing-header-content: 54px;
9999
--spacing-header-total: calc(var(--header-height) + var(--spacing-header-content));
100+
--scrollbar-width: 0px;
100101
}
101102

102103
@layer base {
@@ -109,6 +110,30 @@
109110
color: var(--color-gray-900);
110111
}
111112

113+
/* Custom Scrollbar - html only */
114+
html::-webkit-scrollbar {
115+
width: 8px;
116+
}
117+
118+
html::-webkit-scrollbar-track {
119+
background: transparent;
120+
}
121+
122+
html::-webkit-scrollbar-thumb {
123+
background: var(--color-gray-400);
124+
border-radius: 4px;
125+
}
126+
127+
html::-webkit-scrollbar-thumb:hover {
128+
background: var(--color-gray-500);
129+
}
130+
131+
/* Firefox */
132+
html {
133+
scrollbar-width: thin;
134+
scrollbar-color: var(--color-gray-400) transparent;
135+
}
136+
112137
/* View Transitions */
113138
@keyframes view-fade-in {
114139
from {

src/pages/chat/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChatPage } from './ui';
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
};
15+
16+
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);
29+
const messageListRef = useRef<HTMLDivElement | null>(null);
30+
31+
const hasSelection = selectedThreadId !== null;
32+
const activeThread = useMemo(
33+
() => (selectedThreadId ? threadContent[selectedThreadId] : null),
34+
[selectedThreadId, threadContent]
35+
);
36+
37+
const markThreadReadIfNeeded = useCallback(
38+
(threadId: string | null) => {
39+
if (!threadId) {
40+
return;
41+
}
42+
if (!unreadByThread[threadId]) {
43+
return;
44+
}
45+
setUnreadByThread((prev) => ({ ...prev, [threadId]: false }));
46+
},
47+
[unreadByThread]
48+
);
49+
50+
const handleSend = (nextMessage: string) => {
51+
if (!selectedThreadId || !nextMessage.trim()) {
52+
setMessage('');
53+
return;
54+
}
55+
56+
const now = new Date();
57+
const metaLabel = formatMetaLabel(now);
58+
59+
setThreadContent((prev) => {
60+
const current = prev[selectedThreadId];
61+
if (!current) {
62+
return prev;
63+
}
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+
};
84+
85+
const scrollToBottom = () => {
86+
const container = messageListRef.current;
87+
if (!container) {
88+
return;
89+
}
90+
container.scrollTop = container.scrollHeight;
91+
};
92+
93+
const handleScroll = useCallback(() => {
94+
if (!selectedThreadId) {
95+
return;
96+
}
97+
const container = messageListRef.current;
98+
if (!container) {
99+
return;
100+
}
101+
const isAtBottom =
102+
container.scrollHeight <= container.clientHeight + 1 ||
103+
container.scrollTop + container.clientHeight >= container.scrollHeight - 2;
104+
if (isAtBottom) {
105+
markThreadReadIfNeeded(selectedThreadId);
106+
}
107+
}, [selectedThreadId, markThreadReadIfNeeded]);
108+
109+
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]);
119+
120+
return {
121+
activeThread,
122+
hasSelection,
123+
message,
124+
messageListRef,
125+
selectedThreadId,
126+
setMessage,
127+
setSelectedThreadId,
128+
unreadByThread,
129+
statusByThread,
130+
setStatusByThread,
131+
handleSend,
132+
handleScroll,
133+
};
134+
};
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { type STATUS_OPTIONS } from '@shared/mocks/data/chat';
2+
import { ChatBubble } from '@shared/ui/ChatBubble';
3+
import { ChatInput } from '@shared/ui/ChatInput';
4+
import { type RefObject } from 'react';
5+
import { ChatConversationHeader } from './ChatConversationHeader';
6+
import { ChatStatusDropdown } from './ChatStatusDropdown';
7+
import type { ThreadContent } from '@shared/types/chat';
8+
9+
type ChatConversationProps = {
10+
activeThread: ThreadContent | null;
11+
hasSelection: boolean;
12+
message: string;
13+
messageListRef: RefObject<HTMLDivElement | null>;
14+
onSend: (message: string) => void;
15+
onMessageChange: (value: string) => void;
16+
onScroll: () => void;
17+
activeStatus: (typeof STATUS_OPTIONS)[number];
18+
onStatusChange: (value: (typeof STATUS_OPTIONS)[number]) => void;
19+
};
20+
21+
export const ChatConversation = ({
22+
activeThread,
23+
hasSelection,
24+
message,
25+
messageListRef,
26+
onSend,
27+
onMessageChange,
28+
onScroll,
29+
activeStatus,
30+
onStatusChange,
31+
}: ChatConversationProps) => {
32+
return (
33+
<section className="flex w-full flex-1 flex-col rounded-[24px] bg-gray-50 px-[22px] py-[22px] xl:h-[932px] xl:w-[690px] xl:max-w-[690px] xl:shrink-0">
34+
{!hasSelection ? (
35+
<div className="flex flex-1 items-center justify-center text-gray-400">대화방을 선택해 주세요.</div>
36+
) : !activeThread ? (
37+
<div className="flex flex-1 items-center justify-center text-gray-400">아직 대화 기록이 없습니다.</div>
38+
) : (
39+
<>
40+
<ChatConversationHeader
41+
thread={activeThread}
42+
statusDropdown={<ChatStatusDropdown activeStatus={activeStatus} onStatusChange={onStatusChange} />}
43+
/>
44+
45+
<div
46+
className="mt-[28px] flex min-h-0 flex-1 flex-col gap-[24px] overflow-y-auto pr-2"
47+
ref={messageListRef}
48+
onScroll={onScroll}
49+
>
50+
{activeThread.timeline.map((item) => {
51+
if (item.type === 'date') {
52+
return (
53+
<div key={item.id} className="flex justify-center">
54+
<span className="typo-caption-2 rounded-full bg-white px-4 py-2 text-gray-400">{item.label}</span>
55+
</div>
56+
);
57+
}
58+
59+
const isSender = item.role === 'sender';
60+
return (
61+
<ChatBubble
62+
key={item.id}
63+
variant={isSender ? 'sender' : 'receiver'}
64+
message={item.message}
65+
meta={item.meta}
66+
metaDateTime={item.metaDateTime}
67+
/>
68+
);
69+
})}
70+
</div>
71+
72+
<div className="mt-[24px] w-full max-w-[647px]">
73+
<ChatInput placeholder="메시지를 입력하세요." value={message} onChange={onMessageChange} onSend={onSend} />
74+
</div>
75+
</>
76+
)}
77+
</section>
78+
);
79+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ThreadContent } from '@shared/types/chat';
2+
import type { ReactNode } from 'react';
3+
4+
type ChatConversationHeaderProps = {
5+
thread: ThreadContent;
6+
statusDropdown: ReactNode;
7+
};
8+
9+
export const ChatConversationHeader = ({ thread, statusDropdown }: ChatConversationHeaderProps) => {
10+
return (
11+
<div className="h-[167px] rounded-(--radius-l) bg-gray-900 px-[42px] py-[44px]">
12+
<div className="flex items-center justify-between gap-4">
13+
<div className="flex items-center gap-4">
14+
{thread.product.image ? (
15+
<img
16+
src={thread.product.image}
17+
alt={thread.product.title}
18+
className="h-[80px] w-[80px] rounded-(--radius-m) object-cover"
19+
/>
20+
) : (
21+
<div className="h-[80px] w-[80px] rounded-(--radius-m) bg-gray-200" />
22+
)}
23+
<div className="flex flex-col gap-[6px]">
24+
<span className="typo-body-1 text-white">{thread.product.title}</span>
25+
<span className="typo-body-2 text-white">{thread.product.price}</span>
26+
<span className="typo-caption-2 text-white">{thread.product.date}</span>
27+
</div>
28+
</div>
29+
30+
{statusDropdown}
31+
</div>
32+
</div>
33+
);
34+
};

0 commit comments

Comments
 (0)