diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d3cf039..460ca8b 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -33,6 +33,7 @@ jobs: echo "VITE_KAKAO_PRODUCTION_REDIRECT_URI=${{ secrets.VITE_KAKAO_PRODUCTION_REDIRECT_URI }}" >> .env echo "VITE_KAKAO_DEVELOPE_REDIRECT_URI=${{ secrets.VITE_KAKAO_DEVELOPE_REDIRECT_URI }}" >> .env echo "VITE_GA_MEASUREMENT_ID=${{ secrets.VITE_GA_MEASUREMENT_ID }}" >> .env + echo "VITE_WEBSOCKET_URL=${{ secrets.VITE_WEBSOCKET_URL }}" >> .env - name: Run build run: npm run build # 프로젝트에 맞는 빌드 명령어 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..4f831d5 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18.20.7 \ No newline at end of file diff --git a/README.md b/README.md index 022acd2..8a78a73 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ +# MBTips - 성격 유형 기반 팁 공유 플랫폼 + +🌐 [https://mbtips.kr](https://mbtips.kr) + +MBTips는 MBTI 성격 유형에 따라 다양한 상황별 팁을 확인하고, 다른 유형과 소통할 수 있는 웹 서비스입니다. +사용자는 상대의 성격 유형을 유추해보거나, 유형별 상황 대응법을 확인하고, 궁금한 MBTI와 실시간 채팅을 통해 대화를 나눌 수 있습니다. + +> ⏱️ **개발 기간**: 2025년 2월 ~ 2025년 5월 (약 4개월) +> 🎨 **프론트엔드 담당**: UI/UX 설계, 컴포넌트 구현, 상태 관리, 라우팅 및 API 연동 + +--- + +## 🧩 주요 기능 + +- 🔍 **상대 MBTI 추측 검사하기** + 질문에 답하면서 상대방의 MBTI를 유추해보는 인터랙티브 검사 + +- 💡 **MBTI별 상황별 팁 보기** + 유형별로 상황(연애 등)에 따른 대화 팁과 조언 제공 + +- 💬 **MBTI별 채팅 기능** + 내가 궁금한 MBTI 유형과 실시간 채팅으로 대화 체험 + +--- + +## 🚀 기술 스택 + +### 프론트엔드 + +| 기술 | 설명 | +|------|------| +| **React 18** | SPA 프레임워크 | +| **Vite 6** | 빠른 번들링 및 개발 서버 | +| **TypeScript** | 정적 타입 지원 | +| **Zustand** | 상태 관리 라이브러리 | +| **React Router DOM v7** | 클라이언트 사이드 라우팅 | +| **Tailwind CSS 4** | 유틸리티 기반 CSS 프레임워크 | +| **Axios** | HTTP 클라이언트 | +| **React GA4** | 구글 애널리틱스 연동 | +| **ESLint + Prettier** | 코드 스타일 자동화 + +--- + +## 📁 프로젝트 구조 + +```bash +src/ +├── api/ # API 요청 함수 모음 +├── components/ # 재사용 가능한 UI 컴포넌트 +├── constants/ # 상수 정의 (ex. MBTI 목록, 메시지 등) +├── hooks/ # 커스텀 React Hooks +├── libs/ # 외부 라이브러리 래퍼 (예: GA 등) +├── mock/ # 목업 데이터 및 정적 페이지용 데이터 +├── pages/ # 라우팅되는 주요 페이지 컴포넌트 +├── store/ # Zustand를 활용한 상태 관리 +├── types/ # 전역 TypeScript 타입 정의 +├── utils/ # 공통 유틸리티 함수 +├── App.tsx # 루트 컴포넌트 +├── main.tsx # 앱 진입점 +├── index.css # 글로벌 스타일 +├── global.d.ts # 글로벌 타입 선언 +├── vite-env.d.ts # Vite 환경 타입 선언 +└── ... + + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. diff --git "a/public/image/F\354\235\230_\353\214\200\355\231\224.svg" "b/public/image/F\354\235\230_\353\214\200\355\231\224.svg" new file mode 100644 index 0000000..ef96965 --- /dev/null +++ "b/public/image/F\354\235\230_\353\214\200\355\231\224.svg" @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/public/image/N\354\235\230_\353\214\200\355\231\224.svg" "b/public/image/N\354\235\230_\353\214\200\355\231\224.svg" new file mode 100644 index 0000000..2eb4998 --- /dev/null +++ "b/public/image/N\354\235\230_\353\214\200\355\231\224.svg" @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/image/content_banner_1_lg.svg b/public/image/content_banner_1_lg.svg new file mode 100644 index 0000000..66a98de --- /dev/null +++ b/public/image/content_banner_1_lg.svg @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/public/image/content_banner_1_md.svg b/public/image/content_banner_1_md.svg new file mode 100644 index 0000000..2d85150 --- /dev/null +++ b/public/image/content_banner_1_md.svg @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/public/image/content_banner_2_lg.svg b/public/image/content_banner_2_lg.svg new file mode 100644 index 0000000..b782942 --- /dev/null +++ b/public/image/content_banner_2_lg.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/image/content_banner_2_md.svg b/public/image/content_banner_2_md.svg new file mode 100644 index 0000000..2c02c90 --- /dev/null +++ b/public/image/content_banner_2_md.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index df67517..4616a24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import MbtiTestIntro from "@/pages/MbtiTestIntro"; import MbtiTestQuestions from "@/pages/MbtiTestQuestions"; import MbtiTestResult from "@/pages/MbtiTestResult"; import Error from "@/pages/Error"; + import CenteredLayout from "@/components/CenteredLayout"; import ToastMessage from "@/components/ToastMessage"; import useAuthStore from "@/store/useAuthStore"; @@ -55,10 +56,20 @@ const PageTracker = () => { } // select-info 페이지에서 state로 분기 else if (pathname === "/select-info" && path === pathname) { - if (state === "fastFriend" && page === "빠른 대화 설정") { - trackPageView(path, page); - } else if (state === "virtualFriend" && page === "친구 저장") { - trackPageView(path, page); + if (state.type === "fastFriend") { + trackPageView(path, "빠른 대화 설정"); + } else if (state.type === "virtualFriend") { + trackPageView(path, "친구 저장"); + } else if (state.type === "topicChat") { + trackPageView(path, "오픈채팅 - 내 정보 입력"); + } + } + // chat 페이지에서 state로 분기 + else if (pathname === "/chat" && path === pathname) { + if (state.mode === "topicChat") { + trackPageView(path, "오픈채팅방"); + } else { + trackPageView(path, "채팅방"); } } // 나머지 일반 path @@ -135,6 +146,7 @@ const App = () => { } /> } /> } /> + } /> diff --git a/src/api/openChat.ts b/src/api/openChat.ts new file mode 100644 index 0000000..593467f --- /dev/null +++ b/src/api/openChat.ts @@ -0,0 +1,101 @@ +import { authInstance } from "./axios"; +import { + OpenChatRoom, + OpenChatMessage, + CreateOpenChatRequest, + OpenChatRoomsResponse, + CreateOpenChatResponse +} from "@/types/openChat"; + +/** + * 오픈 채팅방 목록 조회 + */ +export const getOpenChatRooms = async (): Promise => { + try { + const response = + await authInstance.get("/api/open-chat"); + return response.data.data; + } catch (error) { + console.error("Failed to fetch open chat rooms:", error); + throw new Error("오픈 채팅방 목록을 불러올 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 메시지 조회 + * @param openChatId - 오픈 채팅방 ID + * @param openChatMessageId - 마지막 메시지 번호 (선택사항) + */ +export const getOpenChatMessages = async ( + openChatId: number, + openChatMessageId?: number +): Promise<{ messages: OpenChatMessage[]; hasMore: boolean }> => { + try { + const params = openChatMessageId + ? `?openChatMessageId=${openChatMessageId}` + : ""; + const response = await authInstance.get<{ data: OpenChatMessage[] }>( + `/api/open-chat/${openChatId}${params}` + ); + + // API 응답 데이터 검증 - 실제 API 구조에 맞게 수정 + if ( + response.data && + response.data.data && + Array.isArray(response.data.data) + ) { + return { + messages: response.data.data, + hasMore: false // 일단 false로 설정, 필요시 서버에서 제공 + }; + } else { + console.warn("API 응답 데이터가 예상 형식과 다름:", response.data); + return { messages: [], hasMore: false }; + } + } catch (error) { + console.error("Failed to fetch open chat messages:", error); + // 오류 발생 시 빈 데이터 반환 (앱이 중단되지 않도록) + return { messages: [], hasMore: false }; + } +}; + +/** + * 오픈 채팅방 생성 + * @param chatData - 채팅방 생성 데이터 + */ +export const createOpenChatRoom = async ( + chatData: CreateOpenChatRequest +): Promise<{ openChatId: number }> => { + try { + const response = await authInstance.post( + "/api/open-chat", + chatData + ); + return response.data.data; + } catch (error) { + console.error("Failed to create open chat room:", error); + throw new Error("오픈 채팅방을 생성할 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 상세 정보 조회 + * @param openChatId - 오픈 채팅방 ID + */ +export const getOpenChatRoomDetail = async ( + openChatId: number +): Promise => { + try { + const rooms = await getOpenChatRooms(); + const room = rooms.find((room) => room.id === openChatId); + + if (!room) { + throw new Error("채팅방을 찾을 수 없습니다."); + } + + return room; + } catch (error) { + console.error("Failed to fetch open chat room detail:", error); + throw new Error("채팅방 정보를 불러올 수 없습니다."); + } +}; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index e59ed0a..248c869 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -19,16 +19,16 @@ const bannerImages: BannerImage[] = [ description: "내가 궁금한 그 사람의 MBTI는?" }, { - sm: "/image/home_banner2_sm.png", - md: "/image/home_banner2_md.png", - lg: "/image/home_banner2_lg.png", - description: "썸탈 때 대화주제 추천 MBTI별 대백과" + sm: "/image/content_banner_1_md.svg", + md: "/image/content_banner_1_md.svg", + lg: "/image/content_banner_1_lg.svg", + description: "너와 결혼까지 생각했어.. 최애의 결혼생활 망상하기(mbti별)" }, { - sm: "/image/home_banner3_sm.png", - md: "/image/home_banner3_md.png", - lg: "/image/home_banner3_lg.png", - description: "MBTI별 피해야 할 대화스타일 및 주제" + sm: "/image/content_banner_2_md.svg", + md: "/image/content_banner_2_md.svg", + lg: "/image/content_banner_2_lg.svg", + description: "버블로 어떤 칭찬을 해줘야 좋아할까? (mbti별)" } ]; diff --git a/src/components/ChatActionBar.tsx b/src/components/ChatActionBar.tsx index 3247782..10437a7 100644 --- a/src/components/ChatActionBar.tsx +++ b/src/components/ChatActionBar.tsx @@ -9,6 +9,7 @@ interface ChatActionProps { onChange: (e: ChangeEvent) => void; onKeyUp: (e: KeyboardEvent) => void; onSend: () => void; + mode?: string; } const ChatActionBar = ({ @@ -17,12 +18,23 @@ const ChatActionBar = ({ value, onChange, onKeyUp, - onSend + onSend, + mode }: ChatActionProps) => { + const isTopicChat = mode === "topicChat"; + return (
- - + {!isTopicChat && ( + + )} + + { +interface IntroGuideProps { + mode?: string; + chatTitle?: string; +} + +const IntroGuide = ({ mode, chatTitle }: IntroGuideProps) => { + const isTopicChat = mode === "topicChat"; + return (
-

- {`MBTI 대화에 참여하셨군요! - 대화 상황에 대해 구체적으로 - 말씀해주시면,더 좋은 답변을 드릴 수 있어요 :)`} -

-

- 언제, 어디서, 어떤 상황인지 자유롭게 알려주세요 -

+ {isTopicChat ? ( + <> +

+ {`오픈채팅에 오신 걸 환영해요! + ${chatTitle}에 대한 이야기를 자유롭게 나눠보세요`} +

+ + ) : ( + <> +

+ {`MBTI 대화에 참여하셨군요! + 대화 상황에 대해 구체적으로 + 말씀해주시면,더 좋은 답변을 드릴 수 있어요 :)`} +

+

+ 언제, 어디서, 어떤 상황인지 자유롭게 알려주세요 +

+ + )}
); }; diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index f1d2b7b..d580e7f 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -5,14 +5,29 @@ import { VirtualFriend } from "@/types/virtualFreind"; import trackClickEvent from "@/utils/trackClickEvent"; interface ProfileProps { - info: VirtualFriend; - deleteIndex: number; - setVirtualFriendList: React.Dispatch>; + mode: "friend" | "topic"; + info?: VirtualFriend; + deleteIndex?: number; + setVirtualFriendList?: React.Dispatch>; + topicData?: { + chatTitle: string; + description: string; + image: string; + openChatId?: number; + }; } -const Profile = ({ info, deleteIndex, setVirtualFriendList }: ProfileProps) => { +const Profile = ({ + mode, + info, + deleteIndex, + setVirtualFriendList, + topicData +}: ProfileProps) => { const navigate = useNavigate(); const handleDelete = async () => { + if (!info || !setVirtualFriendList || deleteIndex === undefined) return; + trackClickEvent("홈", "친구 - 삭제"); const res = await authInstance.delete( `/api/virtual-friend/${info.virtualFriendId}` @@ -25,49 +40,104 @@ const Profile = ({ info, deleteIndex, setVirtualFriendList }: ProfileProps) => { }; const handleNavigate = () => { - trackClickEvent("홈", "친구 - 바로 대화하기"); - navigate("/chat", { - state: { - mode: "virtualFriend", - mbti: info.mbti, - id: info.virtualFriendId, - name: info.virtualFriendName - } - }); + if (mode === "friend" && info) { + trackClickEvent("홈", "친구 - 바로 대화하기"); + navigate("/chat", { + state: { + mode: "virtualFriend", + mbti: info.mbti, + id: info.virtualFriendId, + name: info.virtualFriendName + } + }); + } else if (mode === "topic" && topicData) { + trackClickEvent("홈", "오픈채팅 입장하기"); + navigate("/select-info", { + state: { + type: "topicChat", + chatTitle: topicData.chatTitle, + description: topicData.description, + openChatId: topicData.openChatId || 1 + } + }); + } + }; + + const getImageSrc = () => { + if (mode === "friend" && info) { + return `/public/image/${info.mbti}_profile.png`; + } else if (mode === "topic" && topicData) { + return topicData.image; + } + return ""; + }; + + const getTitle = () => { + if (mode === "friend" && info) { + return info.virtualFriendName; + } else if (mode === "topic" && topicData) { + return topicData.chatTitle; + } + return ""; + }; + + const getSubtitle = () => { + if (mode === "friend" && info) { + return info.mbti; + } else if (mode === "topic" && topicData) { + return ""; + } + return ""; + }; + + const getDescription = () => { + if (mode === "friend" && info) { + return `${info.virtualFriendAge} · ${info.virtualFriendSex} · ${info.virtualFriendRelationship}`; + } else if (mode === "topic" && topicData) { + return topicData.description; + } + return ""; + }; + + const getButtonText = () => { + return mode === "friend" ? "바로 대화하기" : "오픈채팅 입장하기"; }; return (
- + {mode === "friend" && ( + + )} Profile

- {info.virtualFriendName} - {info.mbti} + {getTitle()} + {getSubtitle() && ( + {getSubtitle()} + )}

-

- {info.virtualFriendAge} · {info.virtualFriendSex} ·{" "} - {info.virtualFriendRelationship} +

+ {getDescription()}

); diff --git a/src/components/ProfileContainer.tsx b/src/components/ProfileContainer.tsx index dd98c7d..69d02f4 100644 --- a/src/components/ProfileContainer.tsx +++ b/src/components/ProfileContainer.tsx @@ -15,6 +15,7 @@ const ProfileContainer = ({ {list.map((el, index) => ( { +const SubTitle = ({ + mode +}: { + mode: "빠른대화" | "친구목록" | "주제별대화방"; +}) => { const [needLoginModalIsOpen, setNeedLoginModalIsOpen] = useState(false); const { isLoggedIn } = useAuthStore(); const navigate = useNavigate(); @@ -17,6 +21,10 @@ const SubTitle = ({ mode }: { mode: "빠른대화" | "친구목록" }) => { 친구목록: { title: "친구 목록", description: "친구 정보와 대화 내용을 저장해요." + }, + 주제별대화방: { + title: "주제별 대화방", + description: "관심 있는 주제로 대화해보세요" } }; diff --git a/src/components/TopicProfileContainer.tsx b/src/components/TopicProfileContainer.tsx new file mode 100644 index 0000000..3266864 --- /dev/null +++ b/src/components/TopicProfileContainer.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; +import Profile from "@/components/Profile"; +import { getOpenChatRooms } from "@/api/openChat"; +import { OpenChatRoom } from "@/types/openChat"; + +type TopicData = { + chatTitle: string; + description: string; + image: string; + openChatId: number; +}; + +const TopicProfileContainer = () => { + const [topicData, setTopicData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadOpenChatRooms = async () => { + try { + const rooms = await getOpenChatRooms(); + const convertedData: TopicData[] = rooms.map((room: OpenChatRoom) => ({ + chatTitle: room.title, + description: room.description, + image: room.imageUrl || "/image/N의_대화.svg", + openChatId: room.id + })); + + if (convertedData.length !== 0) { + setTopicData(convertedData); + } else { + setTopicData([ + { + chatTitle: "N의 대화", + description: "망상력 N% 대화방", + image: "/image/N의_대화.svg", + openChatId: 1 + }, + { + chatTitle: "F의 대화", + description: "F 감성 대화방", + image: "/image/F의_대화.svg", + openChatId: 2 + } + ]); + } + } catch (error) { + console.error("Failed to load open chat rooms:", error); + // 오류 시 기본값 사용 + setTopicData([ + { + chatTitle: "N의 대화", + description: "망상력 N% 대화방", + image: "/image/N의_대화.svg", + openChatId: 1 + }, + { + chatTitle: "F의 대화", + description: "F 감성 대화방", + image: "/image/F의_대화.svg", + openChatId: 2 + } + ]); + } finally { + setIsLoading(false); + } + }; + + loadOpenChatRooms(); + }, []); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + return ( +
+ {topicData.map((topic, index) => ( + + ))} +
+ ); +}; + +export default TopicProfileContainer; diff --git a/src/components/button/FormButton.tsx b/src/components/button/FormButton.tsx index 94eafdd..1810c00 100644 --- a/src/components/button/FormButton.tsx +++ b/src/components/button/FormButton.tsx @@ -13,17 +13,26 @@ const FormButton = ({ children, onClick }: FormButtonProps) => { - const baseStyles = "flex justify-center items-center rounded-lg transition"; + const baseStyles = + "flex justify-center items-center rounded-lg transition-colors transform-none focus:transform-none active:transform-none select-none"; const sizeStyles = size === "sm" ? "min-w-[68px] w-auto h-[40px]" : "w-[70px] h-[72px]"; - const fontSize = - size === "md" - ? "text-[var(--text-2xl)]" - : selected - ? "text-[16px]" - : "text-[14px]"; + const getFontSize = () => { + if (size === "md") return "text-[var(--text-2xl)]"; + + // 긴 텍스트인 경우 작은 폰트 적용 + const isLongText = children.length > 4; + + if (selected) { + return isLongText ? "text-[13px]" : "text-[16px]"; + } else { + return isLongText ? "text-[12px]" : "text-[14px]"; + } + }; + + const fontSize = getFontSize(); const fontWeight = size === "md" ? "font-bold" : selected ? "font-bold" : "font-medium"; @@ -34,8 +43,9 @@ const FormButton = ({ return ( diff --git a/src/components/input/MessageInput.tsx b/src/components/input/MessageInput.tsx index 08eb07c..bfd4d30 100644 --- a/src/components/input/MessageInput.tsx +++ b/src/components/input/MessageInput.tsx @@ -4,13 +4,24 @@ interface MessageInputProps { value: string; onChange: (e: ChangeEvent) => void; onKeyUp: (e: KeyboardEvent) => void; + mode?: string; } -const MessageInput = ({ value, onChange, onKeyUp }: MessageInputProps) => { +const MessageInput = ({ + value, + onChange, + onKeyUp, + mode +}: MessageInputProps) => { + const isTopicChat = mode === "topicChat"; + const widthClasses = isTopicChat + ? "w-[267px] md:w-[282px] lg:w-[407px]" + : "w-[242px] md:w-[257px] lg:w-[382px]"; + return ( { + const navigate = useNavigate(); const { state } = useLocation(); - const { mbti, mode, id = Date.now().toString(), name } = state; + + const { + mbti, + mode, + id = Date.now().toString(), + name, + chatTitle: openChatTitle, + openChatId, + nickname, + description + } = state; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isOpen, setIsOpen] = useState(false); + const [isConnected, setIsConnected] = useState(false); const bottomRef = useRef(null); + const wsCleanupRef = useRef<{ + messageCleanup?: () => void; + connectionCleanup?: () => void; + }>({}); - const chatTitle = name ? `${name}과 대화` : `${mbti}와 대화`; + const chatTitle = + openChatTitle || (name ? `${name}과 대화` : `${mbti}와 대화`); const assistantImgUrl = pickMbtiImage(mbti); const storageKey = `chatMessages_${id}`; + const isTopicChat = mode === "topicChat"; + + useEffect(() => { + if (!isTopicChat) { + return; + } + + // topicChat 유효성 검증 + if (!openChatId || !nickname || !mbti) { + navigate("/"); + return; + } + + const initializeOpenChat = async () => { + try { + // 기존 메시지 로드 + try { + const response = await getOpenChatMessages(openChatId); + + if ( + response && + response.messages && + Array.isArray(response.messages) + ) { + const convertedMessages: Message[] = response.messages.map( + (msg) => { + return { + role: msg.nickname === nickname ? "user" : "assistant", + content: msg.message, + nickname: msg.nickname, + mbti: msg.mbti || undefined, + messageType: msg.messageType || "text" + }; + } + ); + + // 메시지 순서: API에서 최신순으로 오므로 reverse()로 시간순 정렬 + setMessages(convertedMessages.reverse()); + } else { + setMessages([]); + } + } catch (apiError) { + setMessages([]); + } + + // WebSocket 연결 시도 + const wsUrl = + import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080"; + + try { + const connected = await websocketService.connect({ + nickname, + mbti: mbti as Mbti, + openChatId + }); + + if (connected) { + // 핸들러 등록 시 cleanup 함수들을 저장 + const messageCleanup = websocketService.onMessage( + handleWebSocketMessage + ); + const connectionCleanup = + websocketService.onConnectionChange(setIsConnected); + + // cleanup 함수들을 ref에 저장 + wsCleanupRef.current = { + messageCleanup, + connectionCleanup + }; + + setIsConnected(true); + } else { + setIsConnected(false); + } + } catch (wsError) { + console.warn("WebSocket 연결 실패:", wsError); + setIsConnected(false); + } + } catch (error) { + console.error("오픈채팅 초기화 실패:", error); + } + }; + + initializeOpenChat(); + + return () => { + // 웹소켓 핸들러 정리 + if ( + wsCleanupRef.current.messageCleanup || + wsCleanupRef.current.connectionCleanup + ) { + wsCleanupRef.current.messageCleanup?.(); + wsCleanupRef.current.connectionCleanup?.(); + wsCleanupRef.current = {}; + } + + // 웹소켓 연결 해제 + if (isTopicChat && websocketService.isConnected()) { + websocketService.disconnect(); + } + }; + }, [isTopicChat, openChatId, nickname, mbti, navigate]); + useEffect(() => { const fetchMessages = async () => { if (mode === "virtualFriend") { @@ -60,7 +187,7 @@ const Chat = () => { } catch (error) { console.error("채팅 불러오기 실패", error); } - } else { + } else if (!isTopicChat) { const storedMessage = sessionStorage.getItem(storageKey); if (storedMessage) { setMessages(JSON.parse(storedMessage)); @@ -69,13 +196,13 @@ const Chat = () => { }; fetchMessages(); - }, [mode, id, storageKey]); + }, [mode, id, storageKey, isTopicChat]); useEffect(() => { - if (mode !== "virtualFriend") { + if (mode !== "virtualFriend" && !isTopicChat) { sessionStorage.setItem(storageKey, JSON.stringify(messages)); } - }, [messages, mode, storageKey]); + }, [messages, mode, storageKey, isTopicChat]); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -90,15 +217,116 @@ const Chat = () => { setIsOpen(nextState); }; + const handleWebSocketMessage = (wsMessage: WebSocketMessage) => { + if (wsMessage.type === "ERROR") { + // 에러 메시지 처리 + const errorMessage: Message = { + role: "assistant", + content: wsMessage.message, + messageType: "system" + }; + + // 중복 시스템 메시지 방지 + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.messageType === "system" && + lastMessage.content === wsMessage.message + ) { + return prev; + } + return [...prev, errorMessage]; + }); + } else if (wsMessage.type === "NOTICE") { + // 시스템 알림 메시지 처리 (입장/퇴장) + const systemMessage: Message = { + role: "assistant", + content: wsMessage.message, + messageType: "system" + }; + + // 중복 시스템 메시지 방지 + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.messageType === "system" && + lastMessage.content === wsMessage.message + ) { + return prev; + } + return [...prev, systemMessage]; + }); + } else if ( + wsMessage.type === null && + wsMessage.nickname && + wsMessage.message + ) { + // 일반 채팅 메시지 처리 + const newMessage: Message = { + role: wsMessage.nickname === nickname ? "user" : "assistant", + content: wsMessage.message, + nickname: wsMessage.nickname, + mbti: wsMessage.mbti || undefined, + messageType: "text" + }; + setMessages((prev) => [...prev, newMessage]); + } + }; + const handleSend = async (messageToSend: string) => { if (!messageToSend.trim()) return; + setInput(""); + + if (isTopicChat) { + // 사용자 메시지를 즉시 화면에 표시 + const userMessage: Message = { + role: "user", + content: messageToSend, + nickname, + mbti: mbti as string + }; + setMessages((prev) => [...prev, userMessage]); + + // 오픈채팅 WebSocket 전송 + try { + if (websocketService.isConnected()) { + // 실제 WebSocket으로 메시지 전송 + websocketService.sendMessage(messageToSend.trim()); + } else { + // WebSocket이 연결되지 않은 경우 Mock 응답 + setTimeout(() => { + const mockResponse: Message = { + role: "assistant", + content: `[Mock] ${nickname}님의 메시지를 받았습니다! "${messageToSend}"에 대한 응답입니다.`, + nickname: "시스템", + mbti: "ENFP" + }; + setMessages((prev) => [...prev, mockResponse]); + }, 1000); + } + } catch (error) { + console.error("메시지 전송 실패:", error); + // 오류 발생 시 Mock 응답 + setTimeout(() => { + const errorResponse: Message = { + role: "assistant", + content: `메시지 전송에 실패했습니다. Mock 응답으로 대체합니다.`, + nickname: "시스템", + mbti: "ENFP" + }; + setMessages((prev) => [...prev, errorResponse]); + }, 1000); + } + return; + } + + // 기존 AI 채팅 로직 const updatedMessages: Message[] = [ ...messages, { role: "user", content: messageToSend } ]; setMessages(updatedMessages); - setInput(""); try { const url = @@ -138,38 +366,103 @@ const Chat = () => { return ( <> - - - + + +
+ + {/* topicChat 연결 상태 표시 */} + {isTopicChat && ( +
+ {isConnected ? "실시간 연결됨" : "연결 중..."} +
+ )} +
- + {/* 메시지 리스트 */} - {messages.map((msg, idx) => ( -
- {/* 캐릭터 아이콘 */} - {msg.role === "assistant" && ( - MBTI ICON - )} - {/* 채팅 메시지 */} -
- + + {messages.map((msg, idx) => { + // 시스템 메시지 처리 + if (msg.messageType === "system") { + return ( +
+ + {msg.content} + +
+ ); + } + + return ( +
+ {/* 캐릭터 아이콘 또는 사용자 정보 */} + {msg.role === "assistant" && ( +
+ {isTopicChat && msg.nickname ? ( +
+
+ {msg.nickname.charAt(0)} +
+ {msg.mbti && ( + + {msg.mbti} + + )} +
+ ) : ( + MBTI ICON + )} +
+ )} + {/* 채팅 메시지 */} +
+ {isTopicChat && msg.role === "assistant" && msg.nickname && ( +
+ {msg.nickname} +
+ )} + +
-
- ))} + ); + })}
@@ -181,9 +474,12 @@ const Chat = () => { onChange={handleChange} onKeyUp={handleKeyup} onSend={() => handleSend(input)} + mode={mode} /> - {isOpen && } + {mode !== "topicChat" && isOpen && ( + + )}
); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 069ff10..67aaebf 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -11,6 +11,7 @@ import Header from "@/components/header/Header"; import useAuthStore from "@/store/useAuthStore"; import ProfileContainer from "@/components/ProfileContainer"; import { Helmet } from "react-helmet"; +import TopicProfileContainer from "@/components/TopicProfileContainer"; const Home = () => { const navigate = useNavigate(); @@ -47,6 +48,7 @@ const Home = () => {
+
@@ -55,11 +57,21 @@ const Home = () => {
+ +
+
+ +
+
+ +
+
+
-
+
{isLoggedIn && virtualFreindList.length > 0 ? ( { const navigate = useNavigate(); const location = useLocation(); - const { type, mbti: testResultMBTI } = location.state; // type: fastFriend, virtualFriend 두 종류 존재 - const isNameRequired = type === "virtualFriend"; - const headerTitle = - type === "fastFriend" ? "상대방 정보선택" : "친구 저장하기"; - const selectInfoTitle = - type === "fastFriend" + const { + type, + mbti: testResultMBTI, + chatTitle, + description, + openChatId + } = location.state; // type: fastFriend, virtualFriend, topicChat + const isFastFriend = type === "fastFriend"; + const isVirtualFriend = type === "virtualFriend"; + const isTopicChat = type === "topicChat"; + const isNameRequired = isVirtualFriend || isTopicChat; + + const headerTitle = isTopicChat + ? "내 정보입력" + : isFastFriend + ? "상대방 정보선택" + : "친구 저장하기"; + + const selectInfoTitle = isTopicChat + ? `오픈채팅에서 사용할\n닉네임과 MBTI를 입력해 주세요` + : isFastFriend ? `상대방의 MBTI를 선택하면\n대화를 시뮬레이션 해볼 수 있어요` : `친구의 MBTI를\n선택해주세요`; @@ -57,8 +74,7 @@ const SelectInfo = () => { ? testResultMBTI : undefined; - const confirmButtonText = - type === "fastFriend" ? "대화 시작하기" : "친구 저장하기"; + const confirmButtonText = isVirtualFriend ? "친구 저장하기" : "대화 시작하기"; const [selectedMBTI, setSelectedMBTI] = useState<{ [key: string]: string | null; @@ -71,9 +87,10 @@ const SelectInfo = () => { const [name, setName] = useState(""); const [age, setAge] = useState(null); const [gender, setGender] = useState(null); - const [relationship, setRelationship] = useState(null); - const [interest, setInterest] = useState([]); + const [job, setJob] = useState(null); + const [freeSetting, setFreeSetting] = useState(""); const [toastMessage, setToastMessage] = useState(null); + const [isCheckingNickname, setIsCheckingNickname] = useState(false); useEffect(() => { if (mbtiTestResult && mbtiTestResult.length === 4) { @@ -89,27 +106,15 @@ const SelectInfo = () => { const mbtiOptions = ["E", "N", "F", "P", "I", "S", "T", "J"]; const ageOptions = ["10대", "20대", "30대 이상"]; const genderOptions = ["여자", "남자"]; - const relationshipOptions = [ - "부모", - "자녀", - "친구", - "짝사랑", - "이별", - "연인", - "선생님", - "직장동료" - ]; - const interestOptions = [ - "연애", - "결혼", - "취미", - "사회생활", - "여행", - "운동", - "심리", - "뷰티/패션", - "음식", - "인간관계" + const jobOptions = [ + "연습생", + "아이돌", + "스포츠선수", + "배우", + "작가", + "스트리머", + "유튜버", + "프로게이머" ]; const handleMBTISelect = (option: string) => { @@ -125,18 +130,8 @@ const SelectInfo = () => { return selectedMBTI[group] === option; }; - const handleInterestSelect = (option: string) => { - if (interest.includes(option)) { - setInterest((prevInterests) => - prevInterests.filter((item) => item !== option) - ); - } else { - setInterest((prevInterests) => [...prevInterests, option]); - } - }; - - const isInterestSelected = (option: string) => { - return interest.includes(option); + const handleFreeSettingChange = (e: ChangeEvent) => { + setFreeSetting(e.target.value); }; const handleNameChange = (e: ChangeEvent) => { @@ -156,10 +151,98 @@ const SelectInfo = () => { setTimeout(() => setToastMessage(null), 3000); }; + const checkNicknameAvailability = async ( + nicknameToCheck: string + ): Promise => { + if (!openChatId) return true; + + // 환경 변수로 WebSocket 사용 여부 체크 + const useWebSocketServer = + import.meta.env.VITE_USE_WEBSOCKET_SERVER !== "false"; + + if (!useWebSocketServer) { + console.log("🔧 WebSocket 서버 사용 안함 (환경 변수), Mock 모드 사용"); + await new Promise((resolve) => setTimeout(resolve, 800)); + console.log( + `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + ); + return Math.random() > 0.3; // 70% 확률로 사용 가능 + } + + try { + // 현재 선택된 MBTI 조합 생성 + const mbti = + `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; + + console.log("🔍 WebSocket 닉네임 검사 시작:", { + nicknameToCheck, + openChatId, + mbti + }); + + // WebSocket 닉네임 중복 검사 (서버 준비 시 활성화) + return await websocketService.checkNickname( + nicknameToCheck, + openChatId, + mbti + ); + } catch (error) { + console.warn( + "WebSocket nickname check failed, using mock:", + (error as Error).message + ); + + // WebSocket 서버가 준비되지 않았거나 연결 실패 시 Mock 구현으로 fallback + await new Promise((resolve) => setTimeout(resolve, 800)); + console.log( + `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + ); + return Math.random() > 0.3; // 70% 확률로 사용 가능 + } + }; + const handleConfirmButton = async () => { const isMBTIComplete = Object.values(selectedMBTI).every( (val) => val !== null ); + + // topicChat일 때 처리 + if (isTopicChat) { + if (!name.trim()) { + return showToast("닉네임을 입력해주세요"); + } + + if (!isMBTIComplete) { + return showToast("MBTI를 선택해주세요"); + } + + // 닉네임 중복 검사 + setIsCheckingNickname(true); + const isNicknameAvailable = await checkNicknameAvailability(name.trim()); + setIsCheckingNickname(false); + + if (!isNicknameAvailable) { + return showToast("같은 닉네임을 가진 유저가 있어요!"); + } + + // 오픈 채팅방으로 이동 + const mbti = + `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; + trackClickEvent("오픈채팅 - 내 정보 입력", "대화 시작하기"); + navigate("/chat", { + state: { + mode: "topicChat", + mbti, + id: openChatId.toString(), + chatTitle, + description, + openChatId, + nickname: name.trim() + } + }); + return; + } + // 선택한 MBTI값이 하나라도 부재할 경우 if (!isMBTIComplete) { return showToast("MBTI를 선택해주세요"); @@ -174,26 +257,24 @@ const SelectInfo = () => { const commonData = { gender: gender === "남자" ? "MALE" : gender === "여자" ? "FEMALE" : null, mbti, - interests: interest + freeSetting }; - const selectedData = - type === "virtualFriend" - ? { - ...commonData, - friendName: name, - age: mapAgeToNumber(age), - relationship - } - : { - ...commonData, - fastFriendName: name, - fastFriendAge: mapAgeToNumber(age), - fastFriendRelationship: relationship - }; - - const apiUrl = - type === "virtualFriend" ? "api/virtual-friend" : "api/fast-friend"; + const selectedData = isVirtualFriend + ? { + ...commonData, + friendName: name, + age: mapAgeToNumber(age), + job + } + : { + ...commonData, + fastFriendName: name, + fastFriendAge: mapAgeToNumber(age), + fastFriendJob: job + }; + + const apiUrl = isVirtualFriend ? "api/virtual-friend" : "api/fast-friend"; try { const response = await authInstance.post( @@ -202,17 +283,11 @@ const SelectInfo = () => { ); const responseData = response.data.data; - if (type === "virtualFriend" && isVirtualFriendResponse(responseData)) { - trackEvent("Click", { - page: "친구 저장", - element: "친구 저장하기" - }); + if (isVirtualFriend && isVirtualFriendResponse(responseData)) { + trackClickEvent("친구 저장", "친구 저장하기"); navigate("/"); - } else if (type === "fastFriend" && typeof responseData === "number") { - trackEvent("Click", { - page: "빠른 대화 설정", - element: "대화 시작하기" - }); + } else if (isFastFriend && typeof responseData === "number") { + trackClickEvent("빠른 대화 설정", "대화 시작하기"); navigate("/chat", { state: { mbti, @@ -233,29 +308,41 @@ const SelectInfo = () => { -
+
+
-
{/* MBTI 선택 */} -
+

{selectInfoTitle}

@@ -275,129 +362,158 @@ const SelectInfo = () => {
+ {/* 구분선 */}
-
-
-

- 정보 추가 입력 -

+ {!isTopicChat && ( +
+
+

+ 정보 추가 입력 +

- {/* 이름 입력 */} -
- - -
+ {/* 이름 입력 */} +
+ + +
- {/* 나이 선택 */} -
-

- 나이 -

-
- {ageOptions.map((option) => ( - handleButtonClick(option, setAge, age)} - > - {option} - - ))} + {/* 나이 선택 */} +
+

+ 나이 +

+
+ {ageOptions.map((option) => ( + handleButtonClick(option, setAge, age)} + > + {option} + + ))} +
-
- {/* 성별 선택 */} -
-

- 성별 -

-
- {genderOptions.map((option) => ( - handleButtonClick(option, setGender, gender)} - > - {option} - - ))} + {/* 성별 선택 */} +
+

+ 성별 +

+
+ {genderOptions.map((option) => ( + + handleButtonClick(option, setGender, gender) + } + > + {option} + + ))} +
-
- {/* 관계 선택 */} -
-

- 상대방과 나의 관계 -

-
- {relationshipOptions.map((option) => ( - - handleButtonClick(option, setRelationship, relationship) - } - > - {option} - - ))} + {/* 직업 선택 */} +
+

+ 직업 +

+
+ {jobOptions.map((option) => ( + handleButtonClick(option, setJob, job)} + > + {option} + + ))} +
-
- {/* 관심사 선택 */} -
-

- 관심사 -

-
- {interestOptions.map((option) => ( - handleInterestSelect(option)} - > - {option} - - ))} + {/* 자유 설정 */} +
+

+ 자유 설정 +

+
+