- {/* 캐릭터 아이콘 */}
- {msg.role === "assistant" && (
-

- )}
- {/* 채팅 메시지 */}
-
-
+
+ {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}
+
+ )}
+
+ ) : (
+

+ )}
+
+ )}
+ {/* 채팅 메시지 */}
+
+ {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}
-
- ))}
+ {/* 자유 설정 */}
+
+
+
+ )}
+
+ {/* topicChat일 때만 이름 입력 필드 표시 */}
+ {isTopicChat && (
+
+ )}
- {toastMessage && (
-
setToastMessage(null)}
- />
- )}
+ {toastMessage && (
+ setToastMessage(null)}
+ />
+ )}
- {/* 대화 시작 버튼 */}
+ {/* 대화 시작 버튼 */}
+
-
+
>
);
};
diff --git a/src/services/websocket.ts b/src/services/websocket.ts
new file mode 100644
index 0000000..d1ccd63
--- /dev/null
+++ b/src/services/websocket.ts
@@ -0,0 +1,230 @@
+import { WebSocketMessage, WebSocketRequestMessage } from "@/types/openChat";
+import { Mbti } from "@/types/mbti";
+
+export interface WebSocketConfig {
+ nickname: string;
+ mbti: Mbti;
+ openChatId: number;
+}
+
+export class OpenChatWebSocket {
+ private ws: WebSocket | null = null;
+ private config: WebSocketConfig | null = null;
+ private reconnectAttempts = 0;
+ private maxReconnectAttempts = 5;
+ private reconnectDelay = 1000;
+ private messageHandlers = new Set<(message: WebSocketMessage) => void>();
+ private connectionHandlers = new Set<(connected: boolean) => void>();
+
+ constructor(private serverUrl: string) {}
+
+ connect(config: WebSocketConfig): Promise
{
+ return new Promise((resolve, reject) => {
+ // 기존 연결이 있으면 먼저 정리
+ if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
+ this.disconnect();
+ }
+
+ this.config = config;
+
+ const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(config.nickname)}&mbti=${config.mbti}&open_chat_id=${config.openChatId}`;
+
+ try {
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ this.reconnectAttempts = 0;
+ this.notifyConnectionHandlers(true);
+ resolve(true);
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const message: WebSocketMessage = JSON.parse(event.data);
+ this.notifyMessageHandlers(message);
+ } catch (error) {
+ console.error("Failed to parse WebSocket message:", error);
+ }
+ };
+
+ this.ws.onclose = (event) => {
+ this.notifyConnectionHandlers(false);
+
+ if (!event.wasClean && this.shouldReconnect()) {
+ this.scheduleReconnect();
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error("WebSocket error:", error);
+ reject(new Error("Failed to connect to chat server"));
+ };
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+
+ disconnect() {
+ if (this.ws) {
+ this.ws.close(1000, "User disconnected");
+ this.ws = null;
+ }
+
+ // 모든 핸들러 정리
+ this.messageHandlers.clear();
+ this.connectionHandlers.clear();
+
+ this.config = null;
+ this.reconnectAttempts = 0;
+ }
+
+ sendMessage(content: string) {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ throw new Error("WebSocket is not connected");
+ }
+
+ if (!this.config) {
+ throw new Error("WebSocket config is not set");
+ }
+
+ const message: WebSocketRequestMessage = {
+ type: "MESSAGE",
+ mbti: this.config.mbti,
+ nickname: this.config.nickname,
+ message: content,
+ openChatId: this.config.openChatId
+ };
+
+ this.ws.send(JSON.stringify(message));
+ }
+
+ checkNickname(
+ nickname: string,
+ openChatId: number,
+ mbti: Mbti = "ENFP"
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ // config가 없어도 닉네임 체크는 가능하도록 기본값 사용
+ const useMbti = this.config?.mbti || mbti;
+ const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}`;
+
+ const tempWs = new WebSocket(wsUrl);
+
+ tempWs.onopen = () => {
+ // 서버에 닉네임 체크 요청 메시지 전송
+ const payload: WebSocketRequestMessage = {
+ type: "NICKNAME_CHECK",
+ mbti: useMbti,
+ nickname: nickname,
+ message: "",
+ openChatId: openChatId
+ };
+
+ tempWs.send(JSON.stringify(payload));
+ };
+
+ tempWs.onmessage = (event) => {
+ try {
+ const message: WebSocketMessage = JSON.parse(event.data);
+
+ // 닉네임 중복시 ERROR 타입으로 응답
+ if (
+ message.type === "ERROR" &&
+ message.message.includes("닉네임이 중복됩니다")
+ ) {
+ resolve(false);
+ } else if (message.type === "ERROR") {
+ resolve(false);
+ } else {
+ resolve(true);
+ }
+
+ tempWs.close();
+ } catch (error) {
+ console.error("Failed to parse WebSocket message:", error);
+
+ reject(error);
+ tempWs.close();
+ }
+ };
+
+ tempWs.onerror = (error) => {
+ console.error("Failed to check nickname:", error);
+
+ reject(new Error("Failed to check nickname"));
+ };
+
+ tempWs.onclose = (event) => {
+ console.log("WebSocket closed:", event.code, event.reason);
+ };
+ });
+ }
+
+ onMessage(handler: (message: WebSocketMessage) => void) {
+ this.messageHandlers.add(handler);
+
+ return () => {
+ this.messageHandlers.delete(handler);
+ };
+ }
+
+ onConnectionChange(handler: (connected: boolean) => void) {
+ this.connectionHandlers.add(handler);
+
+ return () => {
+ this.connectionHandlers.delete(handler);
+ };
+ }
+
+ isConnected(): boolean {
+ return this.ws?.readyState === WebSocket.OPEN;
+ }
+
+ private shouldReconnect(): boolean {
+ return (
+ this.reconnectAttempts < this.maxReconnectAttempts && this.config !== null
+ );
+ }
+
+ private scheduleReconnect() {
+ setTimeout(
+ () => {
+ if (this.config && this.shouldReconnect()) {
+ this.reconnectAttempts++;
+ console.log(
+ `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`
+ );
+ this.connect(this.config);
+ }
+ },
+ this.reconnectDelay * Math.pow(2, this.reconnectAttempts)
+ );
+ }
+
+ private notifyMessageHandlers(message: WebSocketMessage) {
+ this.messageHandlers.forEach((handler) => {
+ try {
+ handler(message);
+ } catch (error) {
+ console.error("Error in message handler:", error);
+ }
+ });
+ }
+
+ private notifyConnectionHandlers(connected: boolean) {
+ this.connectionHandlers.forEach((handler) => {
+ try {
+ handler(connected);
+ } catch (error) {
+ console.error("Error in connection handler:", error);
+ }
+ });
+ }
+}
+
+const websocketService = new OpenChatWebSocket(
+ import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080"
+);
+
+export default websocketService;
diff --git a/src/types/openChat.ts b/src/types/openChat.ts
new file mode 100644
index 0000000..2542d99
--- /dev/null
+++ b/src/types/openChat.ts
@@ -0,0 +1,79 @@
+export interface OpenChatRoom {
+ id: number;
+ title: string;
+ description: string;
+ imageUrl?: string;
+ participantCount: number;
+ maxParticipants?: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface OpenChatMessage {
+ openChatMessageId: number;
+ openChatId: number;
+ nickname: string;
+ mbti: string | null;
+ message: string;
+ timestamp?: string;
+ messageType?: "text" | "image" | "system";
+}
+
+export interface ChatParticipant {
+ nickname: string;
+ mbti: string;
+ joinedAt: string;
+}
+
+// 웹소켓 요청 메시지 형태
+export interface WebSocketRequestMessage {
+ type: string;
+ mbti: string;
+ nickname: string;
+ message: string;
+ openChatId: number;
+}
+
+// 웹소켓 응답 메시지 형태
+export interface WebSocketMessage {
+ type: "ERROR" | "NOTICE" | null;
+ mbti: string | null;
+ nickname: string | null;
+ message: string;
+ openChatId: number;
+}
+
+export interface CreateOpenChatRequest {
+ title: string;
+ description: string;
+ imageUrl?: string;
+}
+
+export interface OpenChatRoomsResponse {
+ header: {
+ code: number;
+ message: string;
+ };
+ data: OpenChatRoom[];
+}
+
+export interface OpenChatMessagesResponse {
+ header: {
+ code: number;
+ message: string;
+ };
+ data: {
+ messages: OpenChatMessage[];
+ hasMore: boolean;
+ };
+}
+
+export interface CreateOpenChatResponse {
+ header: {
+ code: number;
+ message: string;
+ };
+ data: {
+ openChatId: number;
+ };
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 225e483..74b17be 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -10,7 +10,8 @@
"@/types/*": ["types/*"],
"@/utils/*": ["utils/*"],
"@/constants/*": ["constants/*"],
- "@/libs/*": ["libs/*"]
+ "@/libs/*": ["libs/*"],
+ "@/services/*": ["services/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
diff --git a/vite.config.ts b/vite.config.ts
index 72847d0..2886227 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -58,6 +58,10 @@ export default defineConfig(({ mode }: { mode: string }) => {
{
find: "@/mock",
replacement: path.resolve(__dirname, "src/mock")
+ },
+ {
+ find: "@/services",
+ replacement: path.resolve(__dirname, "src/services")
}
]
}