diff --git a/package-lock.json b/package-lock.json index ef3f9316..703bd2bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "wine", "version": "0.1.0", "dependencies": { + "@chatscope/chat-ui-kit-react": "^2.1.1", + "@chatscope/chat-ui-kit-styles": "^1.4.0", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", @@ -1882,6 +1884,32 @@ "node": ">=6.9.0" } }, + "node_modules/@chatscope/chat-ui-kit-react": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-react/-/chat-ui-kit-react-2.1.1.tgz", + "integrity": "sha512-rCtE9abdmAbBDkAAUYBC1TDTBMZHquqFIZhADptAfHcJ8z8W3XH/z/ZuwBSJXtzi6h1mwCNc3tBmm1A2NLGhNg==", + "license": "MIT", + "dependencies": { + "@chatscope/chat-ui-kit-styles": "^1.2.0", + "@fortawesome/fontawesome-free": "^6.5.2", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "^0.2.2", + "classnames": "^2.2.6", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "prop-types": "^15.7.2", + "react": "^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0", + "react-dom": "^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0" + } + }, + "node_modules/@chatscope/chat-ui-kit-styles": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-styles/-/chat-ui-kit-styles-1.4.0.tgz", + "integrity": "sha512-016mBJD3DESw7Nh+lkKcPd22xG92ghA0VpIXIbjQtmXhC7Ve6wRazTy8z1Ahut+Tbv179+JxrftuMngsj/yV8Q==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -2522,6 +2550,61 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.6.tgz", + "integrity": "sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", + "react": "^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6269,6 +6352,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -9686,7 +9775,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -10107,7 +10195,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -11072,7 +11159,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12164,7 +12250,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -12176,7 +12261,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proxy-from-env": { diff --git a/package.json b/package.json index d2829ec7..a76ec103 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "chromatic": "chromatic --exit-zero-on-changes" }, "dependencies": { + "@chatscope/chat-ui-kit-react": "^2.1.1", + "@chatscope/chat-ui-kit-styles": "^1.4.0", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", diff --git a/public/icons/ic_gpt.svg b/public/icons/ic_gpt.svg new file mode 100644 index 00000000..22b55946 --- /dev/null +++ b/public/icons/ic_gpt.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 00000000..40a206e5 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,35 @@ +export const runtime = "edge"; + +export async function POST(req: Request) { + const { message } = await req.json(); + + const res = await fetch(`${process.env.OPENAI_API_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: "gpt-5-nano", + messages: [ + { + role: "system", + content: + "당신은 와인 추천 전문 어시스턴트입니다. 모든 대답은 자연스러운 한국어로 2~3문장 이내로 간결하게 작성하세요. 사용자의 질문이 와인과 관련이 없으면 '이 AI는 와인에 대한 내용만 답변 가능합니다.'라고만 답하세요. 추천 시에는 간단한 이유를 한 문장으로 덧붙이세요. 불확실한 정보는 추측하지 말고 '해당 정보는 정확히 알 수 없습니다.'라고 답하세요. 이미지나 그림, 사진을 생성하거나 설명해 달라는 요청이 있을 경우 '이 AI는 이미지 관련 기능을 지원하지 않습니다.'라고만 답하세요.", + }, + { role: "user", content: message }, + ], + }), + }); + + if (!res.ok) { + const err = await res.text(); + return new Response(JSON.stringify({ error: err }), { status: 500 }); + } + + const data = await res.json(); + const reply = data.choices?.[0]?.message?.content ?? ""; + return new Response(JSON.stringify({ reply }), { + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a93c92e3..babccdd4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import QueryProvider from "@/providers/query-provider"; import getMe from "@/api/user/get-me"; import KaKaoInitializer from "@/lib/kakao-initializer"; import ToastProvider from "@/providers/toast/toast-provider"; +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; import { ToastContainer } from "react-toastify"; export function generateMetadata() { diff --git a/src/components/button/chat-button.tsx b/src/components/button/chat-button.tsx new file mode 100644 index 00000000..34779f2d --- /dev/null +++ b/src/components/button/chat-button.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { IconButton } from "@/components"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import ChatBot from "../chat-bot/chat-bot"; + +/** + * 채팅 봇을 열기 위한 버튼 컴포넌트 + * @author jikwon + */ + +const ChatButton = () => { + const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + + if (pathname !== "/wines") return null; + + return ( + <> + setIsOpen(!isOpen)} + /> + {isOpen && } + + ); +}; + +export default ChatButton; diff --git a/src/components/chat-bot/chat-bot.tsx b/src/components/chat-bot/chat-bot.tsx new file mode 100644 index 00000000..d2db9ef8 --- /dev/null +++ b/src/components/chat-bot/chat-bot.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { showErrorToast } from "@/lib/toast"; +import { allowScroll, cn, lockingScroll } from "@/lib/utils"; +import { + Avatar, + ChatContainer, + Message, + MessageInput, + MessageList, + MainContainer, + TypingIndicator, +} from "@chatscope/chat-ui-kit-react"; +import { useEffect, useRef, useState } from "react"; +import "./chat-style.css"; + +type Message = { + role: "user" | "assistant"; + id: string; + content: string; + time: string; +}; + +const styles = { + box: "max-w-[200px] text-[16px] tracking-[-0.02em] px-[4px] py-[2px]", +}; + +const ChatBot = ({ open }: { open: boolean }) => { + const [messages, setMessages] = useState([]); + const [isTyping, setIsTyping] = useState(false); + + const addMessage = (message: Message) => { + setMessages((prev) => [...prev, message]); + }; + + const dialogRef = useRef(null); + const currentScrollY = window.scrollY; + + useEffect(() => { + if (!dialogRef.current?.open && open) { + dialogRef.current?.showModal(); + lockingScroll(currentScrollY); + } else { + dialogRef.current?.close(); + } + + return () => { + allowScroll(currentScrollY); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const handleSend = async (text: string) => { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = text; + const cleanText = tempDiv.textContent || tempDiv.innerText || ""; + const clean = cleanText.trim(); + + if (!clean) return; + + addMessage({ + role: "user", + id: Date.now().toString(), + content: clean, + time: new Date().toISOString(), + }); + + try { + setIsTyping(true); + + const r = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: clean }), + }); + + const { reply } = await r.json(); + + addMessage({ + role: "assistant", + id: Date.now().toString(), + content: reply, + time: new Date().toISOString(), + }); + } catch (err) { + showErrorToast("오류가 발생했습니다."); + } finally { + setIsTyping(false); + } + }; + + return ( +
+ + + + } + > + + + 안녕하세요, 무엇을 도와드릴까요? + + + {messages.map((message) => ( +
+ + + {message.content} + + +
+ ))} +
+ +
+
+
+ ); +}; + +export default ChatBot; diff --git a/src/components/chat-bot/chat-style.css b/src/components/chat-bot/chat-style.css new file mode 100644 index 00000000..d19e2f10 --- /dev/null +++ b/src/components/chat-bot/chat-style.css @@ -0,0 +1,59 @@ +.scrollbar-container { + @apply !bg-[#15191b]; +} + +.cs-chat-container { + @apply !h-full !w-full !bg-[#15191b]; +} + +.cs-message__content { + @apply !border !border-[#6F7172] !bg-[#1f2325] !text-white; +} + +.cs-message-list { + @apply !bg-black; +} + +.cs-message-list__typing-indicator-container { + @apply !bg-black !text-black; +} + +.cs-message-input { + @apply !flex-center !gap-4 !border-none !bg-[#15191b] !p-0 !pt-2; +} + +.cs-message-input__content-editor-wrapper { + @apply !flex-1 !rounded-[20px] !bg-[#1f2325]; +} + +.cs-message-input__content-editor { + @apply !bg-[#1f2325] !text-white; +} + +.cs-message-input__content-editor::before { + @apply !text-[#86898C]; +} + +.cs-message { + @apply !pl-0; +} + +.cs-typing-indicator { + @apply !bg-[#15191b]; +} + +.cs-typing-indicator__indicator { + @apply !bg-[#15191b]; +} + +.cs-typing-indicator__text { + @apply !text-white; +} + +.cs-button { + @apply !flex-center !h-[40px] !w-[40px] !rounded-[100%] !bg-[#292e31]; +} + +.svg-inline--fa { + @apply !text-white; +} diff --git a/src/components/icon/static-icon-map.ts b/src/components/icon/static-icon-map.ts index 7d326883..1725873d 100644 --- a/src/components/icon/static-icon-map.ts +++ b/src/components/icon/static-icon-map.ts @@ -22,6 +22,7 @@ import KakaoIcon from "/public/icons/ic-sns-kakao.svg"; import WineIcon from "/public/icons/ic-wine.svg"; import XIcon from "/public/icons/ic-x.svg"; import EmptyStateIcon from "/public/icons/ic-empty-state.svg"; +import GptIcon from "/public/icons/ic_gpt.svg"; const STATIC_ICON_MAP = { ArrowUpIcon, @@ -43,6 +44,7 @@ const STATIC_ICON_MAP = { WineIcon, XIcon, EmptyStateIcon, + GptIcon, }; export type StaticIconName = keyof typeof STATIC_ICON_MAP; diff --git a/src/components/index.ts b/src/components/index.ts index 3ac0bfd1..2156d14a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -24,3 +24,5 @@ export { default as LikeButton } from "./button/like-button"; export { default as Carousel } from "./carousel/carousel"; export { default as EmptyState } from "./empty-state/empty-state"; export { default as FloatingActions } from "./utils/floating-actions"; +export { default as ChatButton } from "./button/chat-button"; +export { default as ChatBot } from "./chat-bot/chat-bot"; diff --git a/src/components/utils/floating-actions.tsx b/src/components/utils/floating-actions.tsx index c9d71a73..3b0d4963 100644 --- a/src/components/utils/floating-actions.tsx +++ b/src/components/utils/floating-actions.tsx @@ -1,6 +1,6 @@ "use client"; import { cn } from "@/lib/utils"; -import { ScrollTopButton } from "@/components"; +import { ChatButton, ScrollTopButton } from "@/components"; /** * 화면 우측 하단에 떠 있는 유틸리티 액션 버튼 모음 * @author yeonsu @@ -10,12 +10,13 @@ const FloatingActions = () => { return (
+
); };