Skip to content

Commit 299070d

Browse files
Merge pull request #47 from 9git9git/SCRUM-35-FE-챗봇-API-연동
Scrum 35 fe 챗봇 api 연동
2 parents c2108bd + 8b660c8 commit 299070d

File tree

39 files changed

+1170
-305
lines changed

39 files changed

+1170
-305
lines changed

actions/user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ export async function getUser() {
2929
headers: {
3030
'Content-Type': 'application/json',
3131
},
32+
cache: 'force-cache',
3233
}
3334
);
34-
3535
const userData = await userResponse.json();
3636

3737
return userData.data;

apis/chat.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
type ChatRequest = {
2+
userId: string;
3+
categoryId: string;
4+
storageId: string;
5+
chat: {
6+
role: string;
7+
content: string;
8+
createdAt: string;
9+
};
10+
};
11+
12+
type StorageRequest = {
13+
userId: string;
14+
categoryId: string;
15+
storage: {
16+
title: string;
17+
createdAt: string;
18+
};
19+
};
20+
21+
export const getChats = async (userId: string, categoryId: string, storageId: string) => {
22+
const response = await fetch(
23+
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}/categories/${categoryId}/storages/${storageId}/chats`
24+
);
25+
const responseJson = await response.json();
26+
if (!response.ok) {
27+
throw new Error('Failed to fetch chats');
28+
}
29+
30+
return responseJson.data;
31+
};
32+
33+
export const addStorage = async ({ userId, categoryId, storage }: StorageRequest) => {
34+
const response = await fetch(
35+
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}/categories/${categoryId}/storages`,
36+
{
37+
method: 'POST',
38+
headers: {
39+
'Content-Type': 'application/json',
40+
},
41+
body: JSON.stringify({ title: storage.title, created_at: storage.createdAt }),
42+
}
43+
);
44+
const responseJson = await response.json();
45+
if (!response.ok) {
46+
throw new Error('Failed to add storage');
47+
}
48+
49+
return responseJson.data;
50+
};
51+
52+
export const addChat = async ({ userId, categoryId, storageId, chat }: ChatRequest) => {
53+
const response = await fetch(
54+
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}/categories/${categoryId}/storages/${storageId}/chats`,
55+
{
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
},
60+
body: JSON.stringify({
61+
role: chat.role,
62+
content: chat.content,
63+
created_at: chat.createdAt,
64+
}),
65+
}
66+
);
67+
const responseJson = await response.json();
68+
if (!response.ok) {
69+
throw new Error('Failed to add chat');
70+
}
71+
72+
return responseJson.data;
73+
};

apis/storage.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Storage, StorageRequest } from '@/types/storage';
2+
3+
export const getStorage = async ({ userId, categoryId }: StorageRequest): Promise<Storage[]> => {
4+
const response = await fetch(
5+
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}/categories/${categoryId}/storages`
6+
);
7+
8+
if (!response.ok) {
9+
throw new Error('Failed to fetch storage');
10+
}
11+
12+
const responseJson = await response.json();
13+
14+
if (responseJson.status_code !== 200) {
15+
throw new Error('Failed to fetch storage');
16+
}
17+
18+
return responseJson.data;
19+
};

app/(service)/(chat)/conversations/[conversationID]/page.tsx

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import { ChatbotIntro } from '@/components/service/chatbot/ChatbotIntro';
4+
import { Chating } from '@/components/service/chatbot/Chating';
5+
import { SELECTED } from '@/constants/chatOption';
6+
import { Chat } from '@/types/chat';
7+
import { useParams } from 'next/navigation';
8+
9+
export default function StoragePage() {
10+
const { selectedId } = useParams();
11+
const selected = SELECTED[selectedId as keyof typeof SELECTED];
12+
const initialChats = [
13+
{
14+
role: 'user',
15+
content: `${selected.title} 해줘!`,
16+
},
17+
{
18+
role: 'assistant',
19+
content: `${selected.description} 관련 내용을 알려드릴게요!`,
20+
},
21+
] as Chat[];
22+
23+
return (
24+
<>
25+
<ChatbotIntro showSelectBox={true} />
26+
<Chating initialChats={initialChats} />
27+
</>
28+
);
29+
}

app/(service)/chatbot/layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ChatbotHeader } from '@/components/service/chatbot/ChatbotHeader';
2+
3+
export default function ChatbotLayout({ children }: { children: React.ReactNode }) {
4+
return (
5+
<div className="flex flex-col h-full w-[440px] bg-beige-base">
6+
<ChatbotHeader />
7+
{children}
8+
</div>
9+
);
10+
}

app/(service)/chatbot/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Chatbot } from "@/components/service/chatbot/Chatbot";
1+
import { Chatbot } from '@/components/service/chatbot/Chatbot';
22

33
export default function ChatbotPage() {
44
return <Chatbot />;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { ChatbotIntro } from '@/components/service/chatbot/ChatbotIntro';
2+
3+
// 페이지 상단 스켈레톤 컴포넌트
4+
function HeaderSkeleton() {
5+
return (
6+
<div className="w-full bg-white border-b border-gray-200 animate-pulse">
7+
<div className="container mx-auto max-w-xl px-4 py-10 flex items-center justify-between">
8+
<div className="flex items-center space-x-3">
9+
<div className="w-8 h-8 bg-gray-300 rounded-full"></div>
10+
<div className="h-4 bg-gray-300 rounded w-20"></div>
11+
</div>
12+
<div className="flex space-x-2">
13+
<div className="w-8 h-8 bg-gray-300 rounded-full"></div>
14+
<div className="w-8 h-8 bg-gray-300 rounded-full"></div>
15+
</div>
16+
</div>
17+
</div>
18+
);
19+
}
20+
21+
export default function ChatbotLoading() {
22+
return (
23+
<>
24+
{/* 페이지 상단 헤더 스켈레톤 */}
25+
<HeaderSkeleton />
26+
27+
{/* 챗봇 인트로 스켈레톤 */}
28+
29+
{/* 채팅 컨텐츠 스켈레톤 */}
30+
<div className="flex flex-col justify-between h-[calc(100%-160px)]">
31+
<div className="flex flex-col gap-3 px-4 py-4 overflow-y-scroll">
32+
{/* 어시스턴트 메시지 스켈레톤 (3개) */}
33+
<AssistantMessageSkeleton width="70%" />
34+
<UserMessageSkeleton width="50%" />
35+
<AssistantMessageSkeleton width="85%" />
36+
<UserMessageSkeleton width="40%" />
37+
<AssistantMessageSkeleton width="60%" />
38+
</div>
39+
40+
{/* 입력창 스켈레톤 */}
41+
<div className="p-4 border-t">
42+
<div className="flex items-center">
43+
<div className="h-10 bg-gray-200 rounded-full animate-pulse w-full"></div>
44+
<div className="h-10 w-10 ml-2 bg-gray-300 rounded-full flex-shrink-0 animate-pulse"></div>
45+
</div>
46+
</div>
47+
</div>
48+
</>
49+
);
50+
}
51+
52+
// 어시스턴트 메시지 스켈레톤 컴포넌트
53+
function AssistantMessageSkeleton({ width }: { width: string }) {
54+
return (
55+
<div className="flex items-start gap-2 justify-start animate-pulse">
56+
<div className="w-6 h-6 rounded-full mt-1 bg-gray-300 flex-shrink-0"></div>
57+
<div className="max-w-[85%]" style={{ width }}>
58+
<div className="px-4 py-3 rounded-xl bg-white shadow">
59+
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
60+
<div className="h-3 bg-gray-200 rounded w-[95%] mb-2"></div>
61+
<div className="h-3 bg-gray-200 rounded w-[90%] mb-2"></div>
62+
<div className="h-3 bg-gray-200 rounded w-[75%]"></div>
63+
</div>
64+
</div>
65+
</div>
66+
);
67+
}
68+
69+
// 사용자 메시지 스켈레톤 컴포넌트
70+
function UserMessageSkeleton({ width }: { width: string }) {
71+
return (
72+
<div className="flex items-start gap-2 justify-end animate-pulse">
73+
<div className="max-w-[85%]" style={{ width }}>
74+
<div className="px-4 py-3 rounded-xl bg-primary/30">
75+
<div className="h-3 bg-primary/20 rounded w-full mb-2"></div>
76+
<div className="h-3 bg-primary/20 rounded w-[90%]"></div>
77+
</div>
78+
</div>
79+
</div>
80+
);
81+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { getUser } from '@/actions/user';
2+
import { ChatbotIntro } from '@/components/service/chatbot/ChatbotIntro';
3+
import { Chating } from '@/components/service/chatbot/Chating';
4+
import { Chat } from '@/types/chat';
5+
import { getChats } from '@/apis/chat';
6+
import { redirect } from 'next/navigation';
7+
import { Suspense } from 'react';
8+
import ChatbotLoading from './loading';
9+
10+
// 채팅 컨텐츠 컴포넌트 - 정렬된 데이터만 반환
11+
async function ChatContent({
12+
user,
13+
storageId,
14+
categoryId,
15+
}: {
16+
user: any;
17+
storageId: string;
18+
categoryId: string;
19+
}) {
20+
try {
21+
// 채팅 내역 가져오기
22+
const initialChats = await getChats(user.id, categoryId, storageId);
23+
24+
// 채팅 내역 시간순 정렬 (오래된 순으로 정렬하여 시간 흐름대로 표시)
25+
const sortedChats = [...initialChats].sort((a, b) => {
26+
const dateA = new Date(a.createdAt || 0);
27+
const dateB = new Date(b.createdAt || 0);
28+
return dateA.getTime() - dateB.getTime(); // 오래된 순 정렬
29+
});
30+
31+
// 채팅 내역이 없는 경우 기본 메시지 표시
32+
if (sortedChats.length === 0) {
33+
sortedChats.push({
34+
role: 'assistant',
35+
content: '안녕하세요! 도움이 필요하신가요?',
36+
});
37+
}
38+
39+
return <Chating initialChats={sortedChats} />;
40+
} catch (error) {
41+
console.error('채팅 내역 로드 중 오류 발생:', error);
42+
43+
// 오류 발생 시 기본 메시지 표시
44+
const fallbackChats = [
45+
{
46+
role: 'assistant',
47+
content:
48+
'채팅 내역을 불러오는 중 문제가 발생했습니다. 새로고침 해보시거나 잠시 후 다시 시도해주세요.',
49+
},
50+
];
51+
52+
return <Chating initialChats={fallbackChats} />;
53+
}
54+
}
55+
56+
// 메인 페이지 컴포넌트
57+
export default async function ChatbotPage({
58+
params,
59+
searchParams,
60+
}: {
61+
params: Promise<{ storageId: string }>;
62+
searchParams: Promise<{ categoryId?: string }>;
63+
}) {
64+
const { storageId } = await params;
65+
const { categoryId } = await searchParams;
66+
67+
// 사용자 정보 가져오기
68+
const user = await getUser();
69+
70+
if (!user) {
71+
// 사용자 정보가 없으면 로그인 페이지로 리다이렉트
72+
redirect(
73+
'/auth/login?redirect=' +
74+
encodeURIComponent(`/chatbot/s/${storageId}?categoryId=${categoryId}`)
75+
);
76+
}
77+
78+
// 카테고리 ID가 없으면 에러 메시지 표시
79+
if (!categoryId) {
80+
return (
81+
<>
82+
<ChatbotIntro showSelectBox={false} />
83+
<div className="flex flex-col justify-center items-center h-[calc(100%-160px)]">
84+
<div className="text-center text-secondary">
85+
<h2 className="text-xl font-bold mb-2">카테고리 정보가 필요합니다</h2>
86+
<p>올바른 카테고리 ID와 함께 페이지에 접근해주세요.</p>
87+
</div>
88+
</div>
89+
</>
90+
);
91+
}
92+
93+
return (
94+
<>
95+
{/* Suspense를 사용하여 컨텐츠 로딩 중에는 loading.tsx의 스켈레톤이 표시됨 */}
96+
<Suspense fallback={<ChatbotLoading />}>
97+
<ChatbotIntro showSelectBox={false} />
98+
<ChatContent user={user} storageId={storageId} categoryId={categoryId} />
99+
</Suspense>
100+
</>
101+
);
102+
}
103+
104+
// 이 함수는 동적 라우트의 정적 생성을 위한 함수입니다 (선택적)
105+
export async function generateStaticParams() {
106+
// 대부분의 경우 동적 페이지이므로 빈 배열 반환
107+
return [];
108+
}

components/providers/UserProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
1313
const fetchUser = async () => {
1414
setIsReady(false);
1515
const user = await getUser();
16-
console.log(user);
16+
1717
if (user) {
1818
updateUser(user);
1919
setIsReady(true);

0 commit comments

Comments
 (0)