diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3557888..132e18c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,7 @@ name: 빌드 자동화 on: push: branches: - - main - - feat/cicd + - dev jobs: build: @@ -14,39 +13,13 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - name: Set up Node.js (언어 환경) + - name: Set up Node.js (또는 다른 언어 환경) uses: actions/setup-node@v2 with: node-version: "20" - - name: Create .env.production file - run: | - echo "MODE=production" > .env.production - echo "VITE_GA_MEASUREMENT_ID=${{ secrets.VITE_GA_MEASUREMENT_ID }}" >> .env.production - - name: Install dependencies - run: npm ci + run: npm install - name: Run build - env: - NODE_ENV: production - run: npm run build -- --mode production - - - name: Docker build & push - run: | - docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t ${{ secrets.DOCKER_USERNAME }}/embitips_front . - docker push ${{ secrets.DOCKER_USERNAME }}/embitips_front - - - name: Deploy to GCP - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.HOST_PROD }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.PRIVATE_KEY }} - script: | - sudo docker stop embitips_front || true - sudo docker rm embitips_front || true - sudo docker pull ${{ secrets.DOCKER_USERNAME }}/embitips_front - sudo docker run -d -p 3000:3000 --name embitips_front ${{ secrets.DOCKER_USERNAME }}/embitips_front - sudo docker image prune -f + run: npm run build diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..e2aa423 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,52 @@ +name: 빌드 자동화 + +on: + push: + branches: + - main + - feat/cicd + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js (언어 환경) + uses: actions/setup-node@v2 + with: + node-version: "20" + + - name: Install dependencies + run: | + npm install + npm install tailwindcss@latest postcss@latest autoprefixer@latest + npm install --save-dev @types/node + + - name: Create .env file # env 파일 생성 단계 추가 + run: | + echo "VITE_GA_MEASUREMENT_ID=${{ secrets.VITE_GA_MEASUREMENT_ID }}" > .env + + - name: Run build + run: npm run build # 프로젝트에 맞는 빌드 명령어 + + - name: Docker build & push + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -t ${{ secrets.DOCKER_USERNAME }}/embitips_front . + docker push ${{ secrets.DOCKER_USERNAME }}/embitips_front + + - name: Deploy to GCP + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST_PROD }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PRIVATE_KEY }} + script: | + sudo docker stop embitips_front || true + sudo docker rm embitips_front || true + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/embitips_front + sudo docker run -d -p 3000:3000 --name embitips_front ${{ secrets.DOCKER_USERNAME }}/embitips_front + sudo docker image prune -f diff --git a/package.json b/package.json index 5d910ff..fd4b2a7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "vite build --mode production", "lint": "eslint .", "preview": "vite preview", - "start": "vite --port 3000" + "start": "vite --port 3000 --mode production" }, "dependencies": { "@tailwindcss/vite": "^4.0.3", diff --git a/src/App.tsx b/src/App.tsx index 6373053..d042267 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import ChatTips from "@/pages/ChatTips"; import ChatTemporature from "@/pages/ChatTemporature"; import Content from "@/pages/Content"; import Login from "@/pages/Login"; +import MyInfo from "@/pages/MyInfo"; import KaKaoLogin from "@/pages/KaKaoLogin"; import MbtiTestIntro from "@/pages/MbtiTestIntro"; import MbtiTestQuestions from "@/pages/MbtiTestQuestions"; @@ -22,24 +23,42 @@ import { initGA, trackPageView } from "@/libs/analytics"; const PageTracker = () => { const location = useLocation(); + const { pathname, state } = location; const trackedPaths = [ - { path: "/", label: "home" }, - { path: "/login", label: "login" }, - { path: "/contents" } + { path: "/", page: "홈", element: "" }, + { path: "/login", page: "로그인/회원가입", element: "로그인" }, + { path: "/contents", page: "일반콘텐츠", element: "" }, + { path: "/my-info", page: "내 정보", element: "" }, + { path: "/chat", page: "채팅방", element: "" }, + { path: "/select-info", page: "빠른 대화 설정", element: "" }, + { path: "/select-info", page: "친구 저장", element: "대화 시작하기" } ]; useEffect(() => { - const { pathname } = location; + const trackedContentPaths = ["/contents/1", "/contents/2"]; - trackedPaths.forEach(({ path, label }) => { - if (path === "/contents" && pathname.startsWith(path)) { - trackPageView(label || pathname); - } else if (pathname === path) { - trackPageView(label || pathname); + trackedPaths.forEach(({ path, page, element }) => { + // 콘텐츠 상세 페이지 (일반 콘텐츠만 추적) + if (trackedContentPaths.includes(pathname)) { + if (path === "/contents") { + trackPageView(path, { page, element }); + } + } + // select-info 페이지에서 state로 분기 + else if (pathname === "/select-info" && path === pathname) { + if (state === "fastFriend" && page === "빠른 대화 설정") { + trackPageView(path, { page, element }); + } else if (state === "virtualFriend" && page === "친구 저장") { + trackPageView(path, { page, element }); + } + } + // 나머지 일반 path + else if (pathname === path && path !== "/select-info") { + trackPageView(path, { page, element }); } }); - }, [location.pathname]); + }, [location.pathname, location.state]); return null; }; @@ -63,6 +82,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/ChatAction.tsx b/src/components/ChatAction.tsx deleted file mode 100644 index 24477af..0000000 --- a/src/components/ChatAction.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ChangeEvent, useState } from "react"; -import ToggleChatTipsButton from "@/components/button/ToggleChatTipsButton"; -import MessageInput from "@/components/input/MessageInput"; - -const ChatAction = () => { - const [isOpen, setIsOpen] = useState(false); - const [value, setValue] = useState(""); - - const handleChange = (e: ChangeEvent) => { - setValue(e.target.value); - }; - - return ( -
- - - 메시지 제출 -
- ); -}; - -export default ChatAction; diff --git a/src/components/ChatActionBar.tsx b/src/components/ChatActionBar.tsx new file mode 100644 index 0000000..1cf7fcf --- /dev/null +++ b/src/components/ChatActionBar.tsx @@ -0,0 +1,38 @@ +import { ChangeEvent, KeyboardEvent } from "react"; +import ToggleChatTipsButton from "@/components/button/ToggleChatTipsButton"; +import MessageInput from "@/components/input/MessageInput"; + +interface ChatActionProps { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + value: string; + onChange: (e: ChangeEvent) => void; + onKeyUp: (e: KeyboardEvent) => void; + onSend: () => void; +} + +const ChatActionBar = ({ + isOpen, + setIsOpen, + value, + onChange, + onKeyUp, + onSend +}: ChatActionProps) => { + return ( +
+ + + 메시지 제출 +
+ ); +}; + +export default ChatActionBar; diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 08d455e..3b82487 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -11,9 +11,9 @@ const ChatMessage = ({ message, myMessage }: ChatMessageProps) => { : "rounded-tl-none border border-gray-100"; return ( -
+
{ return ( -
-

+

+

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

- +

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

); }; diff --git a/src/components/input/MessageInput.tsx b/src/components/input/MessageInput.tsx index c8bb379..08eb07c 100644 --- a/src/components/input/MessageInput.tsx +++ b/src/components/input/MessageInput.tsx @@ -1,17 +1,20 @@ -import { ChangeEvent } from "react"; +import { ChangeEvent, KeyboardEvent } from "react"; interface MessageInputProps { value: string; onChange: (e: ChangeEvent) => void; + onKeyUp: (e: KeyboardEvent) => void; } -const MessageInput = ({ value, onChange }: MessageInputProps) => { +const MessageInput = ({ value, onChange, onKeyUp }: MessageInputProps) => { return ( ); }; diff --git a/src/libs/analytics.ts b/src/libs/analytics.ts index 059f2f9..667c669 100644 --- a/src/libs/analytics.ts +++ b/src/libs/analytics.ts @@ -4,31 +4,28 @@ const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID || ""; const isProduction = import.meta.env.MODE === "production"; -const initGA = () => { - console.log("import.meta.env.MODE", import.meta.env.MODE); +export const initGA = () => { console.log("isProduction", isProduction); console.log("ID", GA_MEASUREMENT_ID); if (isProduction && GA_MEASUREMENT_ID) { + console.log("initGA"); ReactGA.initialize(GA_MEASUREMENT_ID); } }; -const trackPageView = (path: string) => { - console.log("trackPageView 1"); +export const trackPageView = (url: string, params?: Record) => { if (isProduction && GA_MEASUREMENT_ID) { - console.log("trackPageView 2"); - ReactGA.send({ hitType: "pageview", page: path }); + console.log("trackPageView"); + ReactGA.gtag("event", "page_view", { + page_path: url, + ...params + }); } }; -const trackEvent = (category: string, action: string, label?: string) => { +export const trackEvent = (name: string, params?: Record) => { if (isProduction && GA_MEASUREMENT_ID) { - ReactGA.event({ - category, - action, - label - }); + console.log("trackEvent"); + ReactGA.event(name, params); } }; - -export { initGA, trackPageView, trackEvent }; diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index aec311f..b13f21c 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -1,5 +1,121 @@ +import { useEffect, useRef, useState, ChangeEvent, KeyboardEvent } from "react"; +import IntroGuide from "@/components/IntroGuide"; +import Header from "@/components/Header"; +import ChatMessage from "@/components/ChatMessage"; +import ChatActionBar from "@/components/ChatActionBar"; +import pickMbtiImage from "@/utils/pickMbtiImage"; +import instance from "@/api/axios"; + +interface Message { + role: "user" | "assistant"; + content: string; +} + const Chat = () => { - return
Chat 페이지!
; + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const chatTitle = "ENFP와 대화"; //TODO: API 연동 후 수정 필요 + const assistantInfo = "ENFP"; //TODO: API 연동 후 수정 필요 + const assistantImgUrl = pickMbtiImage(assistantInfo); + + const handleSend = async (messageToSend: string) => { + if (!messageToSend.trim()) return; + + const newMessages: Message[] = [ + ...messages, + { role: "user", content: messageToSend } + ]; + setMessages(newMessages); + setInput(""); + + try { + //TODO: API 분기처리 필요 + const response = await instance.post( + "/api/fast-friend/message", + JSON.stringify({ content: messageToSend }) + ); + + setMessages([ + ...newMessages, + { + role: "assistant", + content: JSON.stringify(response.data) || "응답이 없어요" + } + ]); + } catch (e) { + setMessages([ + ...newMessages, + { role: "assistant", content: "오류가 발생했어요. 다시 시도해 주세요." } + ]); + } + }; + + const handleChange = (e: ChangeEvent) => { + setInput(e.target.value); + }; + + const handleKeyup = (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleSend(e.currentTarget.value); + } + }; + + return ( +
+
+ +
+
+ +
+ + {/* 메시지 리스트 */} + {messages.map((msg, index) => ( +
+ {/* 캐릭터 아이콘 */} + {msg.role === "assistant" && ( + MBTI ICON + )} + + {/* 채팅 메시지 */} +
+ +
+
+ ))} + +
+
+ + handleSend(input)} + /> +
+ ); }; export default Chat; diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index 861e17f..c0bd3ec 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -178,8 +178,10 @@ const Content = () => { const handleStartChat = async () => { try { - trackEvent("User", "Clicked Start Chat Button", "Start Chat"); - + trackEvent("Click", { + page: "일반 콘텐츠", + element: "대화 시작하기" + }); const response = await instance.post("/api/fast-friend"); console.log("Success!!", response.data); } catch (error) { diff --git a/src/pages/MyInfo.tsx b/src/pages/MyInfo.tsx new file mode 100644 index 0000000..85b0cbe --- /dev/null +++ b/src/pages/MyInfo.tsx @@ -0,0 +1,45 @@ +import Header from "@/components/Header"; + +interface MenuItem { + label: string; + onClick?: () => void; +} + +const menuItems: MenuItem[] = [ + { label: "이용약관", onClick: () => console.log("이용약관 클릭") }, + { + label: "개인정보처리방침", + onClick: () => console.log("개인정보처리방침 클릭") + }, + { label: "로그아웃", onClick: () => console.log("로그아웃 클릭") }, + { label: "회원탈퇴", onClick: () => console.log("회원탈퇴 클릭") } +]; + +const MyInfo = () => { + return ( +
+
+ +
    + {menuItems.map((item, index) => ( +
  • + + {item.label} + + arrow +
  • + ))} +
+
+ ); +}; + +export default MyInfo; diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index bd33ad3..37dc439 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { ChangeEvent, useState } from "react"; import { useLocation } from "react-router-dom"; import FormButton from "@/components/button/FormButton"; import Header from "@/components/Header"; @@ -85,7 +85,7 @@ const SelectInfo = () => { return interest.includes(option); }; - const handleNameChange = (e: React.ChangeEvent) => { + const handleNameChange = (e: ChangeEvent) => { setName(e.target.value.substring(0, 6)); }; @@ -118,6 +118,7 @@ const SelectInfo = () => { const mbti = `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}`; const commonData = { + gender: gender === "남자" ? "MALE" : gender === "여자" ? "FEMALE" : null, mbti, interests: interest }; @@ -128,17 +129,13 @@ const SelectInfo = () => { ...commonData, friendName: name, age: mapAgeToNumber(age), - relationship, - gender: - gender === "남자" ? "MALE" : gender === "여자" ? "FEMALE" : null + relationship } : { ...commonData, fastFriendName: name, fastFriendAge: mapAgeToNumber(age), - fastFriendRelationship: relationship, - fastFriendSex: - gender === "남자" ? "MALE" : gender === "여자" ? "FEMALE" : null + fastFriendRelationship: relationship }; const apiUrl =