Skip to content

Commit 652af8d

Browse files
authored
Merge pull request #28 from Nexters/feat/#18
[Feat/#18] 채팅 페이지 개발
2 parents 5d4d9be + 21f03a4 commit 652af8d

28 files changed

+963
-192
lines changed

src/app/chats/[chatId]/page.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
1-
import HeaderContent from "@/shared/components/HeaderContent";
2-
import MainContent from "@/shared/components/MainContent";
1+
import Chat from "@/chat/components/Chat";
32

43
export default function ChatPage() {
5-
return (
6-
<>
7-
<HeaderContent>{null}</HeaderContent>
8-
<MainContent>{null}</MainContent>
9-
</>
10-
);
4+
return <Chat />;
115
}

src/chat/apis/getChatMessagesByRoomId.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,24 @@ const schema = z.object({
2424
answers: z.array(z.string()),
2525
tarotName: TarotCardIdSchema.optional(),
2626
tarotResultId: z.number().optional(),
27-
}),
27+
})
2828
),
2929
});
3030

31-
type ChatMessagesByRoomIdData = z.infer<typeof schema>;
31+
export type ChatMessagesByRoomIdData = z.infer<typeof schema>;
3232

33-
const validate = (
34-
data: ChatMessagesByRoomIdResponse,
35-
): ChatMessagesByRoomIdData => {
33+
const validate = (data: ChatMessagesByRoomIdResponse): ChatMessagesByRoomIdData => {
3634
const validatedData = schema.parse(data);
3735
return validatedData;
3836
};
3937

4038
export const getChatMessagesByRoomId = (roomId: number) => {
4139
return apiClient
42-
.get<ChatMessagesByRoomIdResponse>(
43-
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/chat/room/messages`,
44-
{
45-
params: {
46-
roomId,
47-
},
40+
.get<ChatMessagesByRoomIdResponse>(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/chat/room/messages`, {
41+
params: {
42+
roomId,
4843
},
49-
)
44+
})
5045
.then((res) => validate(res.data))
5146
.catch((error) => {
5247
console.error(error);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useChatMessagesContext } from "@/chat/hooks/useChatMessagesStore";
2+
import { useSendChatMessage } from "@/chat/hooks/useSendChatMesasge";
3+
import { delay } from "@/shared/utils/delay";
4+
import { useParams } from "next/navigation";
5+
import { css } from "styled-components";
6+
import ChipButton from "./ChipButton";
7+
8+
type Props = {
9+
open: boolean;
10+
};
11+
12+
export default function AcceptRejectButtons({ open }: Props) {
13+
const { addMessage, deleteMessage } = useChatMessagesContext();
14+
const { mutate: sendChatMessage, isPending: isSendingChatMessage } = useSendChatMessage();
15+
const { chatId } = useParams<{ chatId: string }>();
16+
17+
const rejectMessage = "아니, 얘기 더 들어봐";
18+
const acceptMessage = "좋아! 타로 볼래";
19+
20+
if (!chatId) throw new Error("chatId가 Dynamic Route에서 전달 되어야 합니다.");
21+
22+
const handleAcceptClick = async () => {
23+
addMessage({
24+
messageId: Math.random(),
25+
type: "USER_NORMAL",
26+
sender: "USER",
27+
answers: [acceptMessage],
28+
});
29+
30+
await delay(500);
31+
32+
const loadingMessageId = Math.random();
33+
34+
addMessage({
35+
messageId: loadingMessageId,
36+
type: "SYSTEM_NORMAL_REPLY",
37+
sender: "SYSTEM",
38+
loading: true,
39+
answers: [],
40+
});
41+
42+
sendChatMessage(
43+
{
44+
roomId: Number(chatId),
45+
message: acceptMessage,
46+
intent: "TAROT_ACCEPT",
47+
},
48+
{
49+
onSuccess: (data) => {
50+
deleteMessage(loadingMessageId);
51+
52+
addMessage({
53+
messageId: data.messageId,
54+
type: data.type,
55+
sender: data.sender,
56+
answers: data.answers,
57+
});
58+
},
59+
}
60+
);
61+
};
62+
63+
const handleRejectClick = async () => {
64+
addMessage({
65+
messageId: Math.random(),
66+
type: "USER_NORMAL",
67+
sender: "USER",
68+
answers: [rejectMessage],
69+
});
70+
71+
await delay(500);
72+
73+
const loadingMessageId = Math.random();
74+
75+
addMessage({
76+
messageId: loadingMessageId,
77+
type: "SYSTEM_NORMAL_REPLY",
78+
sender: "SYSTEM",
79+
loading: true,
80+
answers: [],
81+
});
82+
83+
sendChatMessage(
84+
{
85+
roomId: Number(chatId),
86+
message: rejectMessage,
87+
intent: "TAROT_DECLINE",
88+
},
89+
{
90+
onSuccess: (data) => {
91+
deleteMessage(loadingMessageId);
92+
93+
addMessage({
94+
messageId: data.messageId,
95+
type: data.type,
96+
sender: data.sender,
97+
answers: data.answers,
98+
});
99+
},
100+
}
101+
);
102+
};
103+
104+
if (!open) return null;
105+
106+
return (
107+
<div
108+
css={css`
109+
display: flex;
110+
gap: 8px;
111+
margin-top: 76px;
112+
`}
113+
>
114+
<ChipButton type="button" disabled={isSendingChatMessage} color="primary02" onClick={handleAcceptClick}>
115+
{acceptMessage}
116+
</ChipButton>
117+
<ChipButton type="button" disabled={isSendingChatMessage} color="grey30" onClick={handleRejectClick}>
118+
{rejectMessage}
119+
</ChipButton>
120+
</div>
121+
);
122+
}

src/chat/components/Chat.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use client";
2+
3+
import { ChatMessagesProvider } from "@/chat/hooks/useChatMessagesStore";
4+
import ChatHeader from "./ChatHeader";
5+
import ChatRoom from "./ChatRoom";
6+
7+
export default function Chat() {
8+
// TODO: 채팅 메세지 목록 프리페치 SSR 필요
9+
return (
10+
<>
11+
<ChatHeader />
12+
<ChatMessagesProvider>
13+
<ChatRoom />
14+
</ChatMessagesProvider>
15+
</>
16+
);
17+
}

src/chat/components/ChatAvatar.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { css } from "styled-components";
2+
3+
export default function ChatAvatar() {
4+
// TODO: 이미지로 교체
5+
return (
6+
<div
7+
css={css`
8+
width: 36px;
9+
height: 36px;
10+
border-radius: 50%;
11+
background-color: ${({ theme }) => theme.colors.grey10};
12+
border: 1px solid ${({ theme }) => theme.colors.grey20};
13+
`}
14+
/>
15+
);
16+
}

src/chat/components/ChatBubble.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { MessageSenderType } from "@/chat/models/messageSender";
2+
import { TarotCardIdType } from "@/tarot/models/tarotCardId";
3+
import styled, { css, keyframes, useTheme } from "styled-components";
4+
5+
const fadeInOut = keyframes`
6+
0% {
7+
opacity: 0;
8+
}
9+
50% {
10+
opacity: 1;
11+
}
12+
100% {
13+
opacity: 0;
14+
}
15+
`;
16+
17+
type Props = {
18+
sender: MessageSenderType;
19+
message?: string;
20+
card?: TarotCardIdType;
21+
loading?: boolean;
22+
};
23+
// TODO: 말풍선 컴포넌트 리팩터
24+
export default function ChatBubble({ sender, message, card, loading }: Props) {
25+
const theme = useTheme();
26+
27+
if (sender === "USER") {
28+
return (
29+
<div
30+
css={css`
31+
padding: 8px 12px;
32+
background-color: ${({ theme }) => theme.colors.primary01};
33+
${({ theme }) => theme.fonts.body3}
34+
color: ${({ theme }) => theme.colors.grey90};
35+
border-radius: 8px;
36+
max-width: 260px;
37+
margin-left: auto;
38+
white-space: pre-wrap;
39+
`}
40+
>
41+
{message}
42+
</div>
43+
);
44+
}
45+
46+
if (loading) {
47+
return (
48+
<div
49+
css={css`
50+
display: flex;
51+
align-items: center;
52+
justify-content: center;
53+
gap: 4px;
54+
background-color: ${({ theme }) => theme.colors.grey10};
55+
border-radius: 8px;
56+
height: 40px;
57+
padding-inline: 12px;
58+
width: fit-content;
59+
`}
60+
>
61+
<Dot $delay={0} $color={theme.colors.primary01} />
62+
<Dot $delay={0.3} $color={theme.colors.primary02} />
63+
<Dot $delay={0.6} $color={theme.colors.primary03} />
64+
</div>
65+
);
66+
}
67+
68+
if (card) {
69+
return (
70+
<div // TODO: 이미지로 교체
71+
css={css`
72+
background-color: ${({ theme }) => theme.colors.grey50};
73+
border-radius: 8px;
74+
width: 100px;
75+
height: 160px;
76+
`}
77+
/>
78+
);
79+
}
80+
81+
return (
82+
<div
83+
css={css`
84+
padding: 8px 12px;
85+
background-color: ${({ theme }) => theme.colors.grey10};
86+
${({ theme }) => theme.fonts.body3}
87+
border-radius: 8px;
88+
max-width: 260px;
89+
color: ${({ theme }) => theme.colors.grey90};
90+
white-space: pre-wrap;
91+
`}
92+
>
93+
{message}
94+
</div>
95+
);
96+
}
97+
98+
const Dot = styled.span<{ $delay: number; $color: string }>`
99+
width: 6px;
100+
height: 6px;
101+
background-color: ${({ $color }) => $color};
102+
border-radius: 50%;
103+
animation: ${fadeInOut} 1.5s infinite ease-in-out;
104+
animation-delay: ${({ $delay }) => $delay}s;
105+
`;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { MessageType } from "@/chat/models/message";
2+
import { css, styled } from "styled-components";
3+
import ChatAvatar from "./ChatAvatar";
4+
import ChatBubble from "./ChatBubble";
5+
6+
type Props = {
7+
message: MessageType;
8+
isJustSent: boolean;
9+
};
10+
11+
export default function ChatBubbleGroup({ message }: Props) {
12+
// TODO: 응답을 새로 받은 경우에만 메세지를 순차적으로 렌더링
13+
const renderMessage = (message: MessageType) => {
14+
if (message.tarotName) {
15+
return <ChatBubble key={message.messageId} sender={"SYSTEM"} card={message.tarotName} />;
16+
}
17+
18+
if (message.loading) {
19+
return <ChatBubble key={message.messageId} sender={"SYSTEM"} loading />;
20+
}
21+
22+
const addIdToMessages = (messages: string[]) => {
23+
return messages.map((answer) => ({ messageId: Math.random(), sender: "SYSTEM", message: answer }));
24+
};
25+
return addIdToMessages(message.answers).map((answer) => (
26+
<ChatBubble key={answer.messageId} sender={"SYSTEM"} message={answer.message} />
27+
));
28+
};
29+
30+
return (
31+
<div
32+
css={css`
33+
display: flex;
34+
gap: 8px;
35+
`}
36+
>
37+
<ChatAvatar />
38+
<div
39+
css={css`
40+
display: flex;
41+
flex-direction: column;
42+
gap: 4px;
43+
`}
44+
>
45+
<Nickname>타로냥</Nickname>
46+
<div
47+
css={css`
48+
display: flex;
49+
flex-direction: column;
50+
gap: 4px;
51+
`}
52+
>
53+
{renderMessage(message)}
54+
</div>
55+
</div>
56+
</div>
57+
);
58+
}
59+
60+
const Nickname = styled.p`
61+
${({ theme }) => theme.fonts.subHead1}
62+
color: ${({ theme }) => theme.colors.grey90};
63+
`;

0 commit comments

Comments
 (0)