From 001e7b73303d1fc990de2a1530430c27d0b4f50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 16:51:27 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[#12]=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 10 + package.json | 1 + src/App.js | 5 +- src/components/DailyModal.jsx | 64 +++++ src/components/icons/CalendarIcon.jsx | 22 ++ src/components/icons/ChatIcon.jsx | 22 ++ src/components/icons/GoogleLogoIcon.jsx | 2 +- src/components/icons/MiniScheduleIcon.jsx | 57 ++++ src/components/layout/HeaderNav.jsx | 34 ++- src/pages/HomePage.jsx | 302 +++++++++++++++++++++- src/pages/LoginPage.jsx | 4 +- 11 files changed, 498 insertions(+), 25 deletions(-) create mode 100644 src/components/DailyModal.jsx create mode 100644 src/components/icons/CalendarIcon.jsx create mode 100644 src/components/icons/ChatIcon.jsx create mode 100644 src/components/icons/MiniScheduleIcon.jsx diff --git a/package-lock.json b/package-lock.json index 802c233..c147d1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0", + "react-ios-pwa-prompt": "^2.0.6", "react-modal": "^3.16.1", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", @@ -13991,6 +13992,15 @@ "react": "*" } }, + "node_modules/react-ios-pwa-prompt": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/react-ios-pwa-prompt/-/react-ios-pwa-prompt-2.0.6.tgz", + "integrity": "sha512-slRvFlcYsOL01D8gbJW4agOQ64pk7V+0EBk+d8z8wQ40vtcaoSw/JJkh+fJQrWSfWFVvZESu3FHP0+un4YUjJg==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 9794b7a..a3bcaf5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0", + "react-ios-pwa-prompt": "^2.0.6", "react-modal": "^3.16.1", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", diff --git a/src/App.js b/src/App.js index ef2b121..5cc4e04 100644 --- a/src/App.js +++ b/src/App.js @@ -23,7 +23,10 @@ const App = () => { <Route path="/" element={<HomePage />} /> <Route path="/timetable" element={<SchedulePage />} /> <Route path="/chattinglist" element={<ChattingListPage />} /> - <Route path="/chat/chatRoom/:chatRoomId" element={<ChattingDetailPage />} /> + <Route + path="/chat/chatRoom/:chatRoomId" + element={<ChattingDetailPage />} + /> <Route path="/mypage" element={<MyPage />} /> <Route path="/login" element={<LoginPage />} /> </Routes> diff --git a/src/components/DailyModal.jsx b/src/components/DailyModal.jsx new file mode 100644 index 0000000..e8cc4df --- /dev/null +++ b/src/components/DailyModal.jsx @@ -0,0 +1,64 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; + +const DailyModal = ({ show, onClose }) => { + const [doNotShowToday, setDoNotShowToday] = useState(false); + + const handleDoNotShowTodayChange = (e) => { + setDoNotShowToday(e.target.checked); + }; + + const handleClose = () => { + if (doNotShowToday) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + localStorage.setItem("modalDismissedUntil", tomorrow.toISOString()); + } + onClose(); + }; + + if (!show) return null; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> + <div className="w-11/12 max-w-lg p-6 bg-white shadow-lg rounded-2xl"> + <div className="flex flex-col items-center gap-4 text-center"> + <img + src={`${process.env.PUBLIC_URL}/logo196.png`} + alt="Modal" + className="w-1/2" + /> + <p className="text-gray-700"> + 홈화면에 앱 추가하고 <br /> + 공지사항, 이벤트 알림을 받아보세요. + </p> + </div> + <Link + to="/guide" + className="block px-6 py-2 mt-4 font-bold text-center text-white bg-blue-700 rounded-full hover:bg-blue-800" + > + 설치없이 앱으로 열기 + </Link> + <div className="flex items-center justify-between mt-6"> + <label className="flex items-center text-sm text-gray-600"> + <input + type="checkbox" + checked={doNotShowToday} + onChange={handleDoNotShowTodayChange} + className="mr-2" + /> + 오늘은 보지 않기 + </label> + <button + onClick={handleClose} + className="px-4 py-2 text-sm border rounded-lg hover:bg-gray-100" + > + 닫기 + </button> + </div> + </div> + </div> + ); +}; + +export default DailyModal; diff --git a/src/components/icons/CalendarIcon.jsx b/src/components/icons/CalendarIcon.jsx new file mode 100644 index 0000000..ed843ea --- /dev/null +++ b/src/components/icons/CalendarIcon.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +const CalendarIcon = () => { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={2} + stroke="white" + className="w-8 h-8" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + /> + </svg> + ); +}; + +export default CalendarIcon; diff --git a/src/components/icons/ChatIcon.jsx b/src/components/icons/ChatIcon.jsx new file mode 100644 index 0000000..47fbdff --- /dev/null +++ b/src/components/icons/ChatIcon.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +const ChatIcon = () => { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={2} + stroke="white" + className="w-8 h-8" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M8 10h8m-4 4h4m-9.293 5.707a1 1 0 01-1.414-1.414l1.293-1.293A1 1 0 005 16.586V8a3 3 0 013-3h8a3 3 0 013 3v8a3 3 0 01-3 3H9.414l-1.293 1.293z" + /> + </svg> + ); +}; + +export default ChatIcon; diff --git a/src/components/icons/GoogleLogoIcon.jsx b/src/components/icons/GoogleLogoIcon.jsx index a28ef03..b4a61be 100644 --- a/src/components/icons/GoogleLogoIcon.jsx +++ b/src/components/icons/GoogleLogoIcon.jsx @@ -1,4 +1,4 @@ -export default function GoogleLogo({ className, ...props }) { +export default function GoogleLogoIcon({ className, ...props }) { return ( <svg xmlns="http://www.w3.org/2000/svg" diff --git a/src/components/icons/MiniScheduleIcon.jsx b/src/components/icons/MiniScheduleIcon.jsx new file mode 100644 index 0000000..9d2662d --- /dev/null +++ b/src/components/icons/MiniScheduleIcon.jsx @@ -0,0 +1,57 @@ +import React from "react"; + +const days = ["월", "화", "수", "목", "금", "토", "일"]; + +// 더미 데이터: 요일별 일정 상태 (고정 또는 유동) +const dummyScheduleStatus = [ + { day: "월", type: "fixed" }, + { day: "화", type: "flexible" }, + { day: "수", type: "empty" }, + { day: "목", type: "fixed" }, + { day: "금", type: "flexible" }, + { day: "토", type: "empty" }, + { day: "일", type: "flexible" }, +]; + +const MiniScheduleIcon = ({ scheduleStatus = dummyScheduleStatus }) => { + return ( + <div className="flex flex-col items-center justify-center h-40 p-4 bg-white rounded-lg shadow-lg w-60"> + {/* 헤더 */} + <div className="mb-2 text-gray-300 label-1"> + <span className="text-secondary-500">고정</span> /{" "} + <span className="text-primary-500">유동</span> + </div> + + {/* 요일별 상태 */} + <div className="grid w-full grid-cols-7 gap-1"> + {days.map((day, index) => { + const status = + scheduleStatus.find((item) => item.day === day)?.type || "empty"; + return ( + <div + key={index} + className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${ + status === "fixed" + ? "bg-primary-300 text-white" + : status === "flexible" + ? "bg-secondary-300 text-white" + : "bg-gray-200 text-gray-500" + }`} + title={`${day} ${ + status === "fixed" + ? "고정 일정 있음" + : status === "flexible" + ? "유동 일정 있음" + : "일정 없음" + }`} + > + {day} + </div> + ); + })} + </div> + </div> + ); +}; + +export default MiniScheduleIcon; diff --git a/src/components/layout/HeaderNav.jsx b/src/components/layout/HeaderNav.jsx index 2dba5ad..f274349 100644 --- a/src/components/layout/HeaderNav.jsx +++ b/src/components/layout/HeaderNav.jsx @@ -3,6 +3,9 @@ import { useNavigate } from "react-router-dom"; import Button from "../Button"; import LogoIcon from "../icons/LogoIcon"; import useAuthStore from "../../store/authStore"; +import ChatIcon from "../icons/ChatIcon"; +import GoogleLogoIcon from "../icons/GoogleLogoIcon"; +import CalendarIcon from "../icons/CalendarIcon"; export default function HeaderNav() { const navigate = useNavigate(); @@ -37,21 +40,30 @@ export default function HeaderNav() { <Button size="icon" theme="purple" - icon={<LogoIcon fillColor="#ffffff" />} + icon={<CalendarIcon />} onClick={navigateToTimeTable} /> <Button size="icon" theme="mix" - icon={<LogoIcon fillColor="#ffffff" />} + icon={<ChatIcon />} onClick={navigateToChattingList} /> - <Button - size="icon" - theme="black" - icon={<LogoIcon fillColor="#ffffff" />} - onClick={user ? navigateToMyPage : navigateToLogin} // 조건부 이동 - /> + {user ? ( + <Button + size="icon" + theme="black" + icon={<LogoIcon fillColor="#ffffff" />} + onClick={navigateToMyPage} + /> + ) : ( + <Button + size="icon" + theme="black" + icon={<GoogleLogoIcon />} + onClick={navigateToLogin} + /> + )} </> ) : ( <> @@ -66,7 +78,7 @@ export default function HeaderNav() { <Button size="lg" theme="purple" - icon={<LogoIcon fillColor="#ffffff" />} + icon={<CalendarIcon />} onClick={navigateToTimeTable} > 캘린더 @@ -74,7 +86,7 @@ export default function HeaderNav() { <Button size="lg" theme="mix" - icon={<LogoIcon fillColor="#ffffff" />} + icon={<ChatIcon />} onClick={navigateToChattingList} > 번개채팅방 @@ -92,7 +104,7 @@ export default function HeaderNav() { <Button size="lg" theme="black" - icon={<LogoIcon fillColor="#ffffff" />} + icon={<GoogleLogoIcon />} onClick={navigateToLogin} > 로그인 diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 01e46fe..877f32a 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,8 +1,91 @@ -import React, { useEffect } from "react"; +import React, { useState, useEffect } from "react"; import useAuthStore from "../store/authStore"; +import GetUserPermission from "../fcm/GetUserPermission"; +import DailyModal from "../components/DailyModal"; +import PWAPrompt from "react-ios-pwa-prompt"; +import { useNavigate } from "react-router-dom"; +import Button from "../components/Button"; +import GoogleLogoIcon from "../components/icons/GoogleLogoIcon"; +import MiniScheduleIcon from "../components/icons/MiniScheduleIcon"; +import LogoIcon from "../components/icons/LogoIcon"; +import ChatIcon from "../components/icons/ChatIcon"; const HomePage = () => { const { user, fetchSession } = useAuthStore(); // Zustand 상태 및 메서드 가져오기 + const [isLoading, setIsLoading] = useState(false); + const [showModal, setShowModal] = useState(false); + const [isPWAInstalled, setIsPWAInstalled] = useState(false); + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showInstallPrompt, setShowInstallPrompt] = useState(false); + const [isIOS, setIsIOS] = useState(false); + const [shouldShowPWAPrompt, setShouldShowPWAPrompt] = useState(false); + const [showPushNotificationPrompt, setShowPushNotificationPrompt] = + useState(false); + + const features = [ + { + title: "Google OAuth 간편 로그인", + description: "Google 계정을 통해 빠르고 간편하게 로그인하세요.", + component: ( + <Button size="md" theme="white" icon={<GoogleLogoIcon />}> + 구글로 로그인 + </Button> + ), + }, + { + title: "고정 및 유동적인 시간표 관리", + description: + "고정 시간표와 유동 시간표를 쉽게 관리하고, 일정을 효율적으로 조율하세요.", + component: <MiniScheduleIcon />, // 예시 컴포넌트 + }, + { + title: "친구 초대 및 번개 모임 관리", + description: + "친구를 초대하고 번개 모임을 생성하여 쉽고 빠르게 약속을 잡으세요.", + component: ( + <div className="flex gap-2"> + <Button + size="icon" + theme="purple" + icon={<LogoIcon fillColor="#ffffff" />} + /> + <Button + size="icon" + theme="indigo" + icon={<LogoIcon fillColor="#ffffff" />} + /> + <Button + size="icon" + theme="mix" + icon={<LogoIcon fillColor="#ffffff" />} + /> + </div> + ), + }, + { + title: "실시간 채팅 및 푸시 알림", + description: + "모임 중 실시간으로 소통하고, 알림을 통해 중요한 정보를 놓치지 마세요.", + component: ( + <div className="flex flex-col items-center justify-center w-40 h-40 p-4 rounded-lg shadow-lg bg-primary-100"> + <div className="flex items-center justify-center w-16 h-16 rounded-full shadow-md bg-primary-500"> + <ChatIcon /> + </div> + <p className="mt-4 text-sm font-bold text-primary-500">채팅</p> + </div> + ), + }, + ]; + + const navigate = useNavigate(); + + const handleStartNow = () => { + if (user) { + navigate("/chat-room"); // 번개 채팅방으로 리다이렉션 + } else { + navigate("/login"); // 로그인 페이지로 리다이렉션 + } + }; useEffect(() => { const fetchUserSession = async () => { @@ -16,16 +99,215 @@ const HomePage = () => { fetchUserSession(); }, [fetchSession]); // 페이지 마운트 시 실행 + //PWA 권한 허용 및 FCM 토큰 받아오기 + useEffect(() => { + GetUserPermission(setIsLoading); + }, []); + + useEffect(() => { + const isDeviceIOS = + /iPad|iPhone|iPod/.test(window.navigator.userAgent) && !window.MSStream; + setIsIOS(isDeviceIOS); + + if (window.matchMedia("(display-mode: standalone)").matches) { + setIsPWAInstalled(true); + const isFirstTimeOpen = localStorage.getItem("isFirstTimeOpen"); + if (!isFirstTimeOpen) { + setShowPushNotificationPrompt(true); + localStorage.setItem("isFirstTimeOpen", "false"); + } + return; + } + + const dismissedUntil = localStorage.getItem("modalDismissedUntil"); + if (dismissedUntil) { + const now = new Date(); + if (new Date(dismissedUntil) > now) { + return; + } + } + + const handleBeforeInstallPrompt = (e) => { + e.preventDefault(); + console.log("PWA 설치여부", isPWAInstalled); + if (!isPWAInstalled) { + setDeferredPrompt(e); + setShowInstallPrompt(true); + } + }; + + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + + return () => { + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt + ); + }; + }, [isPWAInstalled]); + + useEffect(() => { + if (isIOS) { + setShouldShowPWAPrompt(true); + } + }, [isIOS]); + + const handleInstallClick = () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + deferredPrompt.userChoice.then((choiceResult) => { + if (choiceResult.outcome === "accepted") { + console.log("User accepted the install prompt"); + } else { + console.log("User dismissed the install prompt"); + } + setDeferredPrompt(null); + setShowInstallPrompt(false); + }); + } + }; + + const handleCloseModal = () => { + setShowModal(false); + setShowInstallPrompt(false); + }; + + const handleAllowNotifications = () => { + GetUserPermission(setIsLoading); + setShowPushNotificationPrompt(false); + }; + return ( - <div className="flex flex-col items-center justify-center w-full h-screen space-y-4"> - <h1 className="heading-1">야나와 홈페이지</h1> - {user ? ( - <p className="text-lg text-gray-700"> - 안녕하세요,{" "} - <span className="font-bold text-primary-500">{user.name}</span> 님! - </p> - ) : ( - <p className="text-lg text-gray-700">로그인이 필요합니다.</p> + <div className="flex flex-col items-center justify-center w-full min-h-screen space-y-4 bg-grayscale-50"> + <div className="min-h-screen bg-grayscale-50"> + {/* 헤더 */} + <header className="px-4 py-6 text-center text-white rounded-t-xl bg-gradient-pink"> + <h1 className="font-bold heading-1">번개 모임 관리 플랫폼</h1> + <p className="mt-2 body-1"> + 친구들과의 약속을 쉽고 빠르게 관리하세요! + </p> + </header> + + <section className="grid max-w-5xl grid-cols-1 gap-8 px-6 py-12 mx-auto tablet:grid-cols-2"> + {features.map((feature, index) => ( + <div + key={index} + className="flex flex-col items-center p-6 bg-white rounded-lg shadow-lg" + > + {/* 컴포넌트 렌더링 */} + <div className="flex items-center justify-center w-full h-48 mb-4"> + {feature.component} + </div> + <h2 className="mb-2 heading-2 text-primary-500"> + {feature.title} + </h2> + <p className="text-center text-gray-600 body-1"> + {feature.description} + </p> + </div> + ))} + </section> + + {/* 번개 모임 예시 섹션 */} + <section className="py-12 bg-primary-50"> + <div className="max-w-5xl mx-auto text-center"> + <h2 className="mb-4 heading-2 text-primary-500">번개 모임 예시</h2> + <p className="px-4 mb-8 text-gray-600 body-1"> + 실시간 번개 모임으로 친구들과 약속을 쉽게 잡아보세요! + </p> + {/* 기존 Card 컴포넌트 활용 */} + <div className="grid grid-cols-1 gap-6 px-6 tablet:grid-cols-2"> + <div className="p-4 bg-white rounded shadow"> + <h3 className="mb-2 heading-3">🎉 번개 회식</h3> + <p className="text-gray-600 body-2"> + 오늘 저녁 7시, 강남역 모임 + </p> + </div> + <div className="p-4 bg-white rounded shadow"> + <h3 className="mb-2 heading-3">🍻 번개 술 약속</h3> + <p className="text-gray-600 body-2">내일 오후 9시, 홍대입구</p> + </div> + </div> + </div> + </section> + + {/* 시작하기 버튼 섹션 */} + <section className="flex flex-col items-center justify-center py-12 text-center bg-white"> + <h2 className="mb-4 heading-2">지금 바로 시작해보세요!</h2> + <Button onClick={handleStartNow} size="xl" theme="mix"> + YANAWA 시작하기 + </Button> + </section> + </div> + + {showInstallPrompt && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> + <div className="w-11/12 max-w-lg p-6 text-center bg-white rounded-xl"> + <img + className="w-1/2 mx-auto" + src={`${process.env.PUBLIC_URL}/logo196.png`} + alt="Modal" + /> + <h2 className="mt-4 text-lg font-bold"> + YHANAWA를 설치하고 <br /> 번개모임을 만들어보세요! + </h2> + <p className="my-4 text-sm text-gray-600"> + 앱에서 푸시 알림을 받을 수 있어요. + </p> + <button + className="px-6 py-2 font-bold text-white bg-blue-700 rounded-full" + onClick={handleInstallClick} + > + 설치 + </button> + <button + className="mt-4 text-sm text-gray-500" + onClick={handleCloseModal} + > + 나중에 설치 + </button> + </div> + </div> + )} + + {showPushNotificationPrompt && ( + <div className="fixed inset-0 flex flex-col items-center justify-center bg-white"> + <img + className="w-24 h-24 mb-4" + alt="알람" + src={`${process.env.PUBLIC_URL}/icons/notiOn.svg`} + /> + <h1 className="text-xl font-bold">푸시 알림 받기</h1> + <p className="my-2 text-center text-gray-600"> + 푸시 알림을 설정하고 각종 공지사항, 키워드 알림을 받아보세요! + </p> + <button + className="px-6 py-2 font-bold text-white bg-blue-700 rounded-full" + onClick={handleAllowNotifications} + > + 알림 받기 + </button> + <button + className="mt-4 text-sm text-gray-500" + onClick={() => setShowPushNotificationPrompt(false)} + > + 나중에 받을게요 + </button> + </div> + )} + {showModal && <DailyModal onClose={handleCloseModal} />} + {isIOS && ( + <PWAPrompt + promptOnVisit={1} + timesToShow={1} + copyTitle="야나와 앱 설치하기 - 아이폰" + copySubtitle="홈 화면에 앱을 추가하고 번개모임 생성 알림을 받아보세요." + copyDescription="야나와는 앱설치 없이 홈화면에 추가를 통해 사용할 수 있습니다." + copyShareStep="하단 메뉴에서 '공유' 아이콘을 눌러주세요." + copyAddToHomeScreenStep="아래의 '홈 화면에 추가' 버튼을 눌러주세요." + appIconPath="%PUBLIC_URL%/ios/192.png" + isShown={shouldShowPWAPrompt} + /> )} </div> ); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index f2ad8ff..c5061e4 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import GoogleLogo from "../components/icons/GoogleLogoIcon"; +import GoogleLogoIcon from "../components/icons/GoogleLogoIcon"; import Button from "../components/Button"; import { getLoginUrl } from "../api/auth"; import useAuthStore from "../store/authStore"; @@ -65,7 +65,7 @@ const LoginPage = () => { <Button size="md" theme="white" - icon={<GoogleLogo />} + icon={<GoogleLogoIcon />} onClick={handleGoogleLogin} > 구글로 로그인 -- GitLab From dafb4e64f0afb36eb6380893b2e7419410252c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 16:58:16 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20useState=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fcm/GetUserPermission.jsx | 24 +----------------------- src/pages/HomePage.jsx | 5 ++--- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/fcm/GetUserPermission.jsx b/src/fcm/GetUserPermission.jsx index 621f1a7..c00186c 100644 --- a/src/fcm/GetUserPermission.jsx +++ b/src/fcm/GetUserPermission.jsx @@ -1,5 +1,4 @@ import GetFCMToken from "./GetFCMToken"; -import { registerServiceWorker } from "../serviceWorkerRegistration"; import Swal from "sweetalert2"; const Toast = Swal.mixin({ @@ -14,18 +13,8 @@ const Toast = Swal.mixin({ }, }); -const GetUserPermission = async (setIsLoading) => { +const GetUserPermission = async () => { try { - //서비스워커 추가 - // await navigator.serviceWorker.register("firebase-messaging-sw.js"); - - // const registrations = await navigator.serviceWorker.getRegistrations(); - // if (registrations.length === 0) { - // setIsLoading(true); - // await registerServiceWorker(); - // setIsLoading(false); - // } - if (!("Notification" in window)) { alert( "알림 서비스를 원활하게 사용하시려면 바탕화면에 바로가기 추가 후, 홈페이지에 종모양아이콘 클릭하여 알림 허용을 해주세요." @@ -39,14 +28,10 @@ const GetUserPermission = async (setIsLoading) => { if (permission === "granted") { try { console.log("Notification permission granted. Ready to send token..."); - setIsLoading(true); await GetFCMToken(); - setIsLoading(false); let isFCMToken = localStorage.getItem("fcmToken"); if (!isFCMToken) { - setIsLoading(true); await GetFCMToken(); - setIsLoading(false); Toast.fire({ icon: "error", title: `알림 토큰 저장 실패`, @@ -61,16 +46,10 @@ const GetUserPermission = async (setIsLoading) => { }); } } else if (permission === "denied") { - // alert("알림권한이 허용되어 있지않습니다. 권한을 허용해 주십시오."); console.log( "Notification permission not granted. Requesting permission..." ); } else { - // Toast.fire({ - // icon: "warning", - // title: `알림 설정 안함`, - // text: "알림 설정 요청을 원하시면 종아이콘을 클릭해주세요.", - // }); } } catch (error) { Toast.fire({ @@ -78,7 +57,6 @@ const GetUserPermission = async (setIsLoading) => { title: `알림 설정 요청 실패`, }); console.error("Failed to check or request notification permission:", error); - setIsLoading(false); } }; diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 877f32a..574a157 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -12,7 +12,6 @@ import ChatIcon from "../components/icons/ChatIcon"; const HomePage = () => { const { user, fetchSession } = useAuthStore(); // Zustand 상태 및 메서드 가져오기 - const [isLoading, setIsLoading] = useState(false); const [showModal, setShowModal] = useState(false); const [isPWAInstalled, setIsPWAInstalled] = useState(false); const [deferredPrompt, setDeferredPrompt] = useState(null); @@ -101,7 +100,7 @@ const HomePage = () => { //PWA 권한 허용 및 FCM 토큰 받아오기 useEffect(() => { - GetUserPermission(setIsLoading); + GetUserPermission(); }, []); useEffect(() => { @@ -173,7 +172,7 @@ const HomePage = () => { }; const handleAllowNotifications = () => { - GetUserPermission(setIsLoading); + GetUserPermission(); setShowPushNotificationPrompt(false); }; -- GitLab From 04a477c3f56cbb562f91b5d23623f96e903e68d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 17:13:28 +0900 Subject: [PATCH 03/13] =?UTF-8?q?hotfix:=20PWA=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/HomePage.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 574a157..50afbd9 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -244,7 +244,7 @@ const HomePage = () => { <div className="w-11/12 max-w-lg p-6 text-center bg-white rounded-xl"> <img className="w-1/2 mx-auto" - src={`${process.env.PUBLIC_URL}/logo196.png`} + src={`${process.env.PUBLIC_URL}/windows11/Square44x44Logo.altform-lightunplated_targetsize-96.png`} alt="Modal" /> <h2 className="mt-4 text-lg font-bold"> @@ -274,7 +274,7 @@ const HomePage = () => { <img className="w-24 h-24 mb-4" alt="알람" - src={`${process.env.PUBLIC_URL}/icons/notiOn.svg`} + src={`${process.env.PUBLIC_URL}/android/android-launchericon-96-96.png`} /> <h1 className="text-xl font-bold">푸시 알림 받기</h1> <p className="my-2 text-center text-gray-600"> @@ -304,7 +304,7 @@ const HomePage = () => { copyDescription="야나와는 앱설치 없이 홈화면에 추가를 통해 사용할 수 있습니다." copyShareStep="하단 메뉴에서 '공유' 아이콘을 눌러주세요." copyAddToHomeScreenStep="아래의 '홈 화면에 추가' 버튼을 눌러주세요." - appIconPath="%PUBLIC_URL%/ios/192.png" + appIconPath={`${process.env.PUBLIC_URL}/ios/192.png`} isShown={shouldShowPWAPrompt} /> )} -- GitLab From 50325995e98eb12a3aef32243cb3a89b4edbaa48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 17:25:11 +0900 Subject: [PATCH 04/13] =?UTF-8?q?design:=20PWA=20modal=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/HomePage.jsx | 52 +++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 50afbd9..b61cc10 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -238,14 +238,13 @@ const HomePage = () => { </Button> </section> </div> - {showInstallPrompt && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> - <div className="w-11/12 max-w-lg p-6 text-center bg-white rounded-xl"> + <div className="flex flex-col items-center justify-center w-11/12 max-w-lg p-6 text-center bg-white rounded-xl"> <img - className="w-1/2 mx-auto" + className="w-1/4 mx-auto" src={`${process.env.PUBLIC_URL}/windows11/Square44x44Logo.altform-lightunplated_targetsize-96.png`} - alt="Modal" + alt="YANAWA" /> <h2 className="mt-4 text-lg font-bold"> YHANAWA를 설치하고 <br /> 번개모임을 만들어보세요! @@ -253,22 +252,17 @@ const HomePage = () => { <p className="my-4 text-sm text-gray-600"> 앱에서 푸시 알림을 받을 수 있어요. </p> - <button - className="px-6 py-2 font-bold text-white bg-blue-700 rounded-full" - onClick={handleInstallClick} - > - 설치 - </button> - <button - className="mt-4 text-sm text-gray-500" - onClick={handleCloseModal} - > - 나중에 설치 - </button> + <div className="flex gap-2"> + <Button size="md" onClick={handleInstallClick}> + 설치 + </Button> + <Button size="md" theme="white" onClick={handleCloseModal}> + 나중에 설치 + </Button> + </div> </div> </div> )} - {showPushNotificationPrompt && ( <div className="fixed inset-0 flex flex-col items-center justify-center bg-white"> <img @@ -280,18 +274,18 @@ const HomePage = () => { <p className="my-2 text-center text-gray-600"> 푸시 알림을 설정하고 각종 공지사항, 키워드 알림을 받아보세요! </p> - <button - className="px-6 py-2 font-bold text-white bg-blue-700 rounded-full" - onClick={handleAllowNotifications} - > - 알림 받기 - </button> - <button - className="mt-4 text-sm text-gray-500" - onClick={() => setShowPushNotificationPrompt(false)} - > - 나중에 받을게요 - </button> + <div className="flex gap-2 mt-4"> + <Button size="md" onClick={handleAllowNotifications}> + 알림 받기 + </Button> + <Button + size="md" + theme="white" + onClick={() => setShowPushNotificationPrompt(false)} + > + 나중에 받을게요 + </Button> + </div> </div> )} {showModal && <DailyModal onClose={handleCloseModal} />} -- GitLab From 08650b167041816b8fb72822e15bc00f8fd06e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 19:17:35 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[#11]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../merge_request_templates.md | 0 src/api/friend.js | 126 +++++++ src/api/meeting.js | 35 ++ src/components/Card.jsx | 59 ++-- src/components/Label.jsx | 2 +- src/components/layout/HeaderNav.jsx | 2 +- src/pages/Mypage.jsx | 325 +++++++++++++++++- src/store/authStore.js | 2 + 8 files changed, 524 insertions(+), 27 deletions(-) create mode 100644 .gitlab/issue_templates/merge_request_templates.md create mode 100644 src/api/friend.js create mode 100644 src/api/meeting.js diff --git a/.gitlab/issue_templates/merge_request_templates.md b/.gitlab/issue_templates/merge_request_templates.md new file mode 100644 index 0000000..e69de29 diff --git a/src/api/friend.js b/src/api/friend.js new file mode 100644 index 0000000..7a5a7c7 --- /dev/null +++ b/src/api/friend.js @@ -0,0 +1,126 @@ +// 기본 API URL +const BASE_URL = process.env.REACT_APP_BASE_URL; + +/** + * 친구 요청 보내기 + * @param {Object} requestData - 요청 데이터 (userId, email) + * @returns {Promise<Object>} - 생성된 친구 요청 데이터 + */ +export const sendFriendRequest = async (requestData) => { + const response = await fetch(`${BASE_URL}/api/friend/request`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error("Failed to send friend request"); + } + + return await response.json(); +}; + +/** + * 받은 친구 요청 조회 + * @returns {Promise<Object[]>} - 받은 친구 요청 리스트 + */ +export const getReceivedFriendRequests = async () => { + const response = await fetch(`${BASE_URL}/api/friend/requests/received`); + + if (!response.ok) { + throw new Error("Failed to fetch received friend requests"); + } + + return (await response.json()).data; +}; + +/** + * 보낸 친구 요청 조회 + * @returns {Promise<Object[]>} - 보낸 친구 요청 리스트 + */ +export const getSentFriendRequests = async () => { + const response = await fetch(`${BASE_URL}/api/friend/requests/sent`); + + if (!response.ok) { + throw new Error("Failed to fetch sent friend requests"); + } + + return (await response.json()).data; +}; + +/** + * 친구 요청 수락 + * @param {number} requestId - 친구 요청 ID + * @returns {Promise<Object>} - 수락된 친구 요청 데이터 + */ +export const acceptFriendRequest = async (requestId) => { + const response = await fetch( + `${BASE_URL}/api/friend/request/${requestId}/accept`, + { + method: "POST", + } + ); + + if (!response.ok) { + throw new Error("Failed to accept friend request"); + } + + return (await response.json()).data; +}; + +/** + * 친구 요청 거절 + * @param {number} requestId - 친구 요청 ID + * @returns {Promise<Object>} - 거절된 친구 요청 데이터 + */ +export const rejectFriendRequest = async (requestId) => { + const response = await fetch( + `${BASE_URL}/api/friend/request/${requestId}/reject`, + { + method: "POST", + } + ); + + if (!response.ok) { + throw new Error("Failed to reject friend request"); + } + + return (await response.json()).data; +}; + +/** + * 친구 목록 조회 + * @param {number} page - 페이지 번호 + * @param {number} size - 페이지 크기 + * @returns {Promise<Object>} - 친구 목록 데이터 + */ +export const getAllFriends = async (page = 0, size = 10) => { + const response = await fetch( + `${BASE_URL}/api/friend/all?page=${page}&size=${size}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch friends list"); + } + + return (await response.json()).data.content; +}; + +/** + * 친구 삭제 + * @param {number} friendId - 삭제할 친구 ID + * @returns {Promise<Object>} - 삭제 결과 데이터 + */ +export const deleteFriend = async (friendId) => { + const response = await fetch(`${BASE_URL}/api/friends/${friendId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete friend"); + } + + return await response.json(); +}; diff --git a/src/api/meeting.js b/src/api/meeting.js new file mode 100644 index 0000000..e4f7611 --- /dev/null +++ b/src/api/meeting.js @@ -0,0 +1,35 @@ +// src/api/meeting.js + +// 기본 API URL +const BASE_URL = process.env.REACT_APP_BASE_URL; + +// 내가 참여하고 있는 채팅방 가져오기 +export const fetchMyMeetings = async (page = 0, size = 20) => { + try { + const response = await fetch( + `${BASE_URL}/api/meeting/my?page=${page}&size=${size}`, + { + method: "GET", + credentials: "include", // 세션 기반 인증을 위해 필요 + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error("Failed to fetch meetings."); + } + + return result.data; // 서버에서 제공된 데이터 반환 + } catch (error) { + console.error("Error fetching my meetings:", error); + throw error; + } +}; diff --git a/src/components/Card.jsx b/src/components/Card.jsx index 5dfcc0e..b721751 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -1,8 +1,10 @@ import { cn } from "../libs/index"; import { cva } from "class-variance-authority"; import React from "react"; +import Label from "./Label"; -const cardVariants = cva("w-[20rem] rounded-xl shadow-lg p-4 overflow-hidden", { +// Card 스타일 변수를 정의합니다. +const cardVariants = cva("w-full rounded-xl shadow-lg p-4 overflow-hidden", { variants: { theme: { black: "bg-black text-white", @@ -11,35 +13,46 @@ const cardVariants = cva("w-[20rem] rounded-xl shadow-lg p-4 overflow-hidden", { purple: "bg-gradient-purple text-white", indigo: "bg-gradient-indigo text-white", mix: "bg-gradient-mix text-white", + gray: "bg-gray-300 text-gray-500", }, }, }); -export default function Card({ - image, - title, - content, - time, - headCount, - theme = "black", - onClick, -}) { - const variantClass = cardVariants({ theme }); +export default function Card({ meeting, theme = "black", onClick }) { + const { + title, + timeIdxStart, + timeIdxEnd, + location, + creatorName, + time_idx_deadline, + type, + } = meeting; + + const variantClass = cardVariants({ + theme: type === "CLOSE" ? "gray" : theme, + }); return ( <div className={cn(variantClass)} onClick={onClick}> - {image && ( - <img - src={image} - alt={title} - className="object-contain w-full h-48 rounded-xl" - /> - )} - <div className="p-3"> - <h3 className="mb-2 text-xl font-bold">{title}</h3> - <p className="text-base">{content}</p> - <p className="text-base">{time}</p> - <p className="mt-5 text-right">{headCount}</p> + <h3 className="mb-2 text-xl font-bold">{title}</h3> + <div className="flex gap-2 mb-2"> + <Label size="sm" theme="indigo"> + {location} + </Label> + <Label size="sm" theme="indigo"> + 시간: {timeIdxStart} ~ {timeIdxEnd} + </Label> + </div> + + <Label size="sm" theme="indigo"> + 마감 시간: {time_idx_deadline} + </Label> + <div className="flex justify-between mt-2"> + <span className="text-sm text-white">작성자: {creatorName}</span> + <span className="text-sm text-right"> + {type === "OPEN" ? "참여 가능" : "참여 마감"} + </span> </div> </div> ); diff --git a/src/components/Label.jsx b/src/components/Label.jsx index 6fae129..8a2edf8 100644 --- a/src/components/Label.jsx +++ b/src/components/Label.jsx @@ -9,7 +9,7 @@ const labelVariants = cva( theme: { indigo: "bg-secondary-900 text-white", solid: "bg-primary-600 text-white", - lightsolid: "bg-primary-100 text-primary-600", + lightsolid: "bg-primary-600 text-primary-100 border border-primary-100", graysolid: "bg-grayscale-100 text-grayscale-900", ghost: "border border-grayscale-500 text-grayscale-900", }, diff --git a/src/components/layout/HeaderNav.jsx b/src/components/layout/HeaderNav.jsx index f274349..426f712 100644 --- a/src/components/layout/HeaderNav.jsx +++ b/src/components/layout/HeaderNav.jsx @@ -89,7 +89,7 @@ export default function HeaderNav() { icon={<ChatIcon />} onClick={navigateToChattingList} > - 번개채팅방 + 번개모임 </Button> {user ? ( <Button diff --git a/src/pages/Mypage.jsx b/src/pages/Mypage.jsx index 7ec5065..2c8c013 100644 --- a/src/pages/Mypage.jsx +++ b/src/pages/Mypage.jsx @@ -1,7 +1,328 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; +import useAuthStore from "../store/authStore"; +import { + getReceivedFriendRequests, + sendFriendRequest, + getAllFriends, + acceptFriendRequest, + rejectFriendRequest, + deleteFriend, +} from "../api/friend"; +import Button from "../components/Button"; +import LogoIcon from "../components/icons/LogoIcon"; +import { fetchMyMeetings } from "../api/meeting"; +import Card from "../components/Card"; +import { useNavigate } from "react-router-dom"; const MyPage = () => { - return <></>; + const { user, fetchSession } = useAuthStore(); // Zustand 상태 및 메서드 가져오기 + const [activeTab, setActiveTab] = useState("lightning"); // 현재 활성화된 탭 + const [receivedRequests, setReceivedRequests] = useState([]); + const [sentRequests, setSentRequests] = useState([]); + const [friends, setFriends] = useState([]); + const [email, setEmail] = useState(""); // 친구 요청할 이메일 + const [page, setPage] = useState(0); // 친구 목록 페이지 + const [hasNext, setHasNext] = useState(true); // 페이지네이션 상태 + const [isLoading, setIsLoading] = useState(false); + + const [meetings, setMeetings] = useState([]); + const [meetingPage, setMeetingPage] = useState(0); + const [meetingHasNext, setMeetingHasNext] = useState(true); + const [meetingIsLoading, setMeetingIsLoading] = useState(false); + + const navigate = useNavigate(); + + // 탭 변경 함수 + const switchTab = (tab) => setActiveTab(tab); + + useEffect(() => { + const fetchUserSession = async () => { + try { + const userInfo = localStorage.getItem("user"); + if (!userInfo) { + alert("로그인이 필요한 페이지입니다."); + navigate("/login"); + } + await fetchSession(); // 세션 정보 가져오기 + } catch (error) { + console.error("Failed to fetch session:", error); + } + }; + + fetchUserSession(); + }, [fetchSession, navigate]); // 페이지 마운트 시 실행 + + // 번개 모임 가져오기 + useEffect(() => { + const fetchMeetings = async () => { + if (!meetingHasNext || meetingIsLoading) return; + + try { + setMeetingIsLoading(true); + const data = await fetchMyMeetings(meetingPage, 20); + setMeetings((prev) => [...prev, ...data.content]); + setMeetingHasNext(data.meetingHasNext); + setMeetingPage((prev) => prev + 1); + } catch (error) { + console.error("Failed to fetch meetings:", error); + } finally { + setIsLoading(false); + } + }; + + if (activeTab === "lightning") fetchMeetings(); + }, [activeTab, meetingPage, meetingHasNext, meetingIsLoading]); + + // 받은 친구 요청 조회 + useEffect(() => { + const fetchReceivedRequests = async () => { + try { + const data = await getReceivedFriendRequests(); + setReceivedRequests(data); + } catch (error) { + console.error("Failed to fetch received requests:", error); + } + }; + if (activeTab === "friends") fetchReceivedRequests(); + }, [activeTab]); + + // 친구 목록 무한스크롤 처리 + useEffect(() => { + const fetchFriends = async () => { + if (!hasNext || isLoading) return; + try { + setIsLoading(true); + const data = await getAllFriends(page, 10); + setFriends((prev) => [...prev, ...data.content]); + setHasNext(data.hasNext); + setPage((prev) => prev + 1); + } catch (error) { + console.error("Failed to fetch friends:", error); + } finally { + setIsLoading(false); + } + }; + if (activeTab === "friends") fetchFriends(); + }, [page, hasNext, activeTab, isLoading]); + + // 친구 요청 보내기 + const handleSendRequest = async () => { + try { + const requestData = { email }; + const response = await sendFriendRequest(requestData); + setSentRequests((prev) => [...prev, response.data]); + setEmail(""); // 입력 필드 초기화 + } catch (error) { + console.error("Failed to send friend request:", error); + } + }; + + // 친구 요청 수락 + const handleAcceptRequest = async (requestId) => { + try { + const response = await acceptFriendRequest(requestId); + setReceivedRequests((prev) => + prev.filter((request) => request.id !== requestId) + ); + setFriends((prev) => [response, ...prev]); // 친구 목록에 추가 + } catch (error) { + console.error("Failed to accept request:", error); + } + }; + + // 친구 요청 거절 + const handleRejectRequest = async (requestId) => { + try { + await rejectFriendRequest(requestId); + setReceivedRequests((prev) => + prev.filter((request) => request.id !== requestId) + ); + } catch (error) { + console.error("Failed to reject request:", error); + } + }; + + // 친구 삭제 + const handleDeleteFriend = async (friendId) => { + try { + await deleteFriend(friendId); + setFriends((prev) => prev.filter((friend) => friend.id !== friendId)); + } catch (error) { + console.error("Failed to delete friend:", error); + } + }; + + return ( + <div className="w-full max-w-screen-lg min-h-screen mx-auto bg-white"> + {/* 프로필 영역 */} + <div className="flex items-center px-6 py-4 border-b"> + <div className="flex-shrink-0"> + <LogoIcon className="w-20 h-20 rounded-full" /> + </div> + <div className="flex-grow ml-6"> + <h1 className="text-xl font-bold">{user?.name || "Guest"}</h1> + <p className="text-gray-600">{user?.email || "guest@example.com"}</p> + </div> + </div> + + {/* 탭 네비게이션 */} + <div className="flex justify-center py-2 space-x-6 border-b"> + <button + className={`px-4 py-2 text-sm font-medium ${ + activeTab === "lightning" + ? "text-secondary-500 border-b-2 border-secondary-500" + : "text-gray-600" + }`} + onClick={() => switchTab("lightning")} + > + 번개 모임 + </button> + <button + className={`px-4 py-2 text-sm font-medium ${ + activeTab === "friends" + ? "text-tertiary-500 border-b-2 border-tertiary-500" + : "text-gray-600" + }`} + onClick={() => switchTab("friends")} + > + 친구 + </button> + </div> + {/* 번개 모임 탭 */} + {activeTab === "lightning" && ( + <div className="p-4"> + {meetings.length === 0 && !isLoading && ( + <p className="text-center">참여 중인 번개 모임이 없습니다.</p> + )} + <div className="grid grid-cols-1 gap-4 tablet:grid-cols-2 desktop:grid-cols-3"> + {meetings.map((meeting) => ( + <Card + key={meeting.id} + meeting={meeting} + theme="purple" + onClick={() => console.log("Clicked meeting:", meeting.id)} + /> + ))} + </div> + {isLoading && <p className="text-center">로딩 중...</p>} + {!hasNext && meetings.length > 0 && ( + <p className="text-sm text-center text-gray-500"> + 더 이상 불러올 번개 모임이 없습니다. + </p> + )} + </div> + )} + + {/* 친구 탭 */} + {activeTab === "friends" && ( + <div className="p-4 space-y-8"> + {/* 친구 요청하기 */} + <div> + <h2 className="text-lg font-bold">친구 요청하기</h2> + <div className="flex items-center my-4 space-x-4"> + <input + type="email" + className="flex-1 min-w-0 p-3 border rounded-full" + placeholder="친구 이메일 입력" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + <Button + size="md" + theme="indigo" + className="min-w-[50px]" + onClick={handleSendRequest} + > + 요청 + </Button> + </div> + <div className="mt-4 space-y-4"> + {sentRequests.map((request) => ( + <div key={request.id} className="p-2 border rounded-lg"> + <p className="text-sm"> + {request.receiver.name} ({request.receiver.email}) + </p> + <p className="text-xs text-gray-500">{request.status}</p> + </div> + ))} + </div> + </div> + <hr /> + {/* 받은 친구 요청 */} + <div> + <h2 className="mb-2 text-lg font-bold">받은 친구 요청</h2> + <div className="space-y-4"> + {receivedRequests.map((request) => ( + <div + key={request.id} + className="flex items-center justify-between p-4 border rounded-lg" + > + <div> + <h3 className="font-semibold text-md"> + {request.requester.name} + </h3> + <p className="text-[10px] tablet:text-sm text-gray-600"> + {request.requester.email} + </p> + </div> + <div className="flex gap-2"> + <Button + size="sm" + theme="indigo" + onClick={() => handleAcceptRequest(request.id)} + > + 수락 + </Button> + <Button + size="sm" + theme="pink" + onClick={() => handleRejectRequest(request.id)} + > + 거절 + </Button> + </div> + </div> + ))} + </div> + </div> + <hr /> + + {/* 친구 목록 */} + <div> + <h2 className="mb-2 text-lg font-bold">친구 목록</h2> + <div className="space-y-4"> + {friends.map((friend) => ( + <div + key={friend.id} + className="flex items-center justify-between p-4 border rounded-lg" + > + <div> + <h3 className="font-semibold text-md"> + {friend.friendInfo.name} + </h3> + <p className="text-[10px] tablet:text-sm text-gray-600"> + {friend.friendInfo.email} + </p> + </div> + <Button + size="sm" + theme="black" + onClick={() => handleDeleteFriend(friend.id)} + > + 삭제 + </Button> + </div> + ))} + {isLoading && <p>로딩 중...</p>} + {!hasNext && ( + <p className="text-center label-2">더 이상 친구가 없습니다.</p> + )} + </div> + </div> + </div> + )} + </div> + ); }; export default MyPage; diff --git a/src/store/authStore.js b/src/store/authStore.js index 05da3b6..5715ec0 100644 --- a/src/store/authStore.js +++ b/src/store/authStore.js @@ -11,6 +11,7 @@ const useAuthStore = create((set) => ({ try { const userInfo = await getSessionInfo(); set({ user: userInfo }); + localStorage.setItem("user", { user: userInfo }); } catch (error) { console.error("Failed to fetch session info:", error); set({ user: null }); @@ -24,6 +25,7 @@ const useAuthStore = create((set) => ({ try { await logout(); set({ user: null }); + localStorage.removeItem("user"); } catch (error) { console.error("Failed to logout:", error); } -- GitLab From 2ec5e31b1f010d6caa20901b6ec0895d3f5b2ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 19:38:32 +0900 Subject: [PATCH 06/13] =?UTF-8?q?hotfix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=ED=99=88=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EB=B0=8F=20=ED=99=88=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B2=84=ED=8A=BC=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/HeaderLogoBar.jsx | 3 +++ src/pages/HomePage.jsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/layout/HeaderLogoBar.jsx b/src/components/layout/HeaderLogoBar.jsx index 5772d41..6483cc9 100644 --- a/src/components/layout/HeaderLogoBar.jsx +++ b/src/components/layout/HeaderLogoBar.jsx @@ -2,16 +2,19 @@ import React, { useState } from "react"; import LogoIcon from "../icons/LogoIcon"; import useAuthStore from "../../store/authStore"; import Button from "../Button"; +import { useNavigate } from "react-router-dom"; const HeaderLogoBar = () => { const { user, logoutUser } = useAuthStore(); // Zustand에서 상태 및 메서드 가져오기 const [loading, setLoading] = useState(false); // 로딩 상태 관리 + const navigate = useNavigate(); // 로그아웃 처리 const handleLogout = async () => { try { setLoading(true); // 로딩 상태 활성화 await logoutUser(); // 로그아웃 실행 + navigate("/"); } catch (error) { console.error("Failed to logout:", error); } finally { diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index b61cc10..e6252be 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -80,7 +80,7 @@ const HomePage = () => { const handleStartNow = () => { if (user) { - navigate("/chat-room"); // 번개 채팅방으로 리다이렉션 + navigate("/chattinglist"); // 번개 채팅방으로 리다이렉션 } else { navigate("/login"); // 로그인 페이지로 리다이렉션 } -- GitLab From 7d7172b29f75ad8bf909bce5d2bb12dccd583bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 20:06:39 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20stor?= =?UTF-8?q?e=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/authStore.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/store/authStore.js b/src/store/authStore.js index 5715ec0..e521680 100644 --- a/src/store/authStore.js +++ b/src/store/authStore.js @@ -11,7 +11,13 @@ const useAuthStore = create((set) => ({ try { const userInfo = await getSessionInfo(); set({ user: userInfo }); - localStorage.setItem("user", { user: userInfo }); + localStorage.setItem("user", userInfo); + const nickname = userInfo.name || "Unknown"; + localStorage.setItem("nickname", nickname); + console.log("반환값 userInfo: " + userInfo); + const localuser = localStorage.getItem("user"); + console.log("user: " + localuser); + console.log("nickname: " + nickname); } catch (error) { console.error("Failed to fetch session info:", error); set({ user: null }); @@ -26,6 +32,7 @@ const useAuthStore = create((set) => ({ await logout(); set({ user: null }); localStorage.removeItem("user"); + localStorage.removeItem("nickname"); } catch (error) { console.error("Failed to logout:", error); } -- GitLab From b7ba7fb01be97a56c4f65370d76ea494b0f7019f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=AC=EC=9E=AC=EC=97=BD?= <jysim0326@ajou.ac.kr> Date: Sun, 8 Dec 2024 20:21:22 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20nickname=20=EB=A1=9C=EC=BB=AC?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/authStore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/authStore.js b/src/store/authStore.js index e521680..a944cdc 100644 --- a/src/store/authStore.js +++ b/src/store/authStore.js @@ -12,7 +12,7 @@ const useAuthStore = create((set) => ({ const userInfo = await getSessionInfo(); set({ user: userInfo }); localStorage.setItem("user", userInfo); - const nickname = userInfo.name || "Unknown"; + const nickname = userInfo.user.name || "Unknown"; localStorage.setItem("nickname", nickname); console.log("반환값 userInfo: " + userInfo); const localuser = localStorage.getItem("user"); -- GitLab From 01ff468272179f56a73cb61c0c732fea860bdff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=AC=EC=9E=AC=EC=97=BD?= <jysim0326@ajou.ac.kr> Date: Sun, 8 Dec 2024 20:21:47 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20api=20=EA=B2=BD=EB=A1=9C=20"/"=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ChattingDetail.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ChattingDetail.jsx b/src/components/ChattingDetail.jsx index ef2a6ae..a163c99 100644 --- a/src/components/ChattingDetail.jsx +++ b/src/components/ChattingDetail.jsx @@ -529,7 +529,7 @@ function ChattingDetail() { const updateLastReadAt = async () => { try { - const response = await fetch(`${process.env.REACT_APP_BASE_URL}api/chat/update-read-status`, { + const response = await fetch(`${process.env.REACT_APP_BASE_URL}/api/chat/update-read-status`, { method: 'POST', headers: { 'Content-Type': 'application/json', -- GitLab From 02a59654bcf53523b3c13c2f50a897f9cbae5219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=AC=EC=9E=AC=EC=97=BD?= <jysim0326@ajou.ac.kr> Date: Sun, 8 Dec 2024 20:22:20 +0900 Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20=EB=8B=89=EB=84=A4=EC=9E=84?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EB=B6=80=EB=B6=84=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ChattingList.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/ChattingList.jsx b/src/components/ChattingList.jsx index 4c079e9..ff4e5d1 100644 --- a/src/components/ChattingList.jsx +++ b/src/components/ChattingList.jsx @@ -116,10 +116,6 @@ function ChattingList() { }, [nickname]); const joinRoom = (chatRoomId, chatRoomName) => { - if (!nickname.trim()) { - alert("닉네임을 입력하세요."); - return; - } setUnreadCounts((prevUnreadCounts) => ({ ...prevUnreadCounts, [chatRoomId]: 0, -- GitLab From c6c79b594c8dde1fc8fe7b8bfa8b2eeefd02cb5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EB=8C=80=ED=9D=AC?= <joedaehui@ajou.ac.kr> Date: Sun, 8 Dec 2024 20:44:41 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[#13]=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SchedulePage.jsx | 333 ++++++++++++++++++++++++++----------- 1 file changed, 239 insertions(+), 94 deletions(-) diff --git a/src/pages/SchedulePage.jsx b/src/pages/SchedulePage.jsx index 35a53ba..5febb0c 100644 --- a/src/pages/SchedulePage.jsx +++ b/src/pages/SchedulePage.jsx @@ -7,6 +7,7 @@ import { fetchAllSchedules, updateSchedule, } from "../api/schedule"; +import Button from "../components/Button"; const generateTimeSlots = () => { const timeSlots = []; @@ -41,8 +42,56 @@ const days = ["월", "화", "수", "목", "금", "토", "일"]; // createdAt: "2024-12-02T09:54:53.000Z", // updatedAt: "2024-12-02T09:54:53.000Z", // }, +// { +// id: 11, +// user_id: 1, +// title: "점심약속", +// is_fixed: false, +// time_indices: [240, 241, 242], +// createdAt: "2024-12-02T09:54:53.000Z", +// updatedAt: "2024-12-02T09:54:53.000Z", +// }, +// { +// id: 14, +// user_id: 1, +// title: "롤 5:5", +// is_fixed: true, +// time_indices: [302, 303, 304, 305, 306, 307], +// createdAt: "2024-12-02T09:54:53.000Z", +// updatedAt: "2024-12-02T09:54:53.000Z", +// }, +// { +// id: 20, +// user_id: 1, +// title: "토트넘 vs 첼시 경기", +// is_fixed: true, +// time_indices: [13, 14, 15, 16, 17, 18], +// createdAt: "2024-12-02T09:54:53.000Z", +// updatedAt: "2024-12-02T09:54:53.000Z", +// }, +// { +// id: 26, +// user_id: 1, +// title: "아침 구보", +// is_fixed: true, +// time_indices: [34, 35, 130, 131, 226, 227, 322, 323, 418, 419, 514, 515, 610, 611], +// createdAt: "2024-12-02T09:54:53.000Z", +// updatedAt: "2024-12-02T09:54:53.000Z", +// }, // ]; +const colorClasses = [ + "bg-indigo-300 hover:bg-indigo-400", + "bg-purple-300 hover:bg-purple-400", + "bg-pink-300 hover:bg-pink-400", + "bg-blue-300 hover:bg-blue-400", + "bg-green-300 hover:bg-green-400", + "bg-yellow-300 hover:bg-yellow-400", + "bg-red-300 hover:bg-red-400", + "bg-orange-300 hover:bg-orange-400", + "bg-teal-300 hover:bg-teal-400", + "bg-cyan-300 hover:bg-cyan-400", +]; const SchedulePage = () => { const timeSlots = generateTimeSlots(); const [schedules, setSchedules] = useState([]); @@ -52,13 +101,22 @@ const SchedulePage = () => { const [selectedSlots, setSelectedSlots] = useState([]); const [newTitle, setNewTitle] = useState(""); const [isFixed, setIsFixed] = useState(true); + const [titleColorMap, setTitleColorMap] = useState(new Map()); + const [showAllTimeSlot, setShowAllTimeSlot] = useState(false); useEffect(() => { // API const initializeSchedules = async () => { try { const data = await fetchAllSchedules(); - setSchedules(data); + + // 스케줄 병합을 위해서 사용 + const sortedSchedules = [...data].sort((a, b) => { + const aMin = Math.min(...a.time_indices); + const bMin = Math.min(...b.time_indices); + return aMin - bMin; + }); + setSchedules(sortedSchedules); } catch (error) { console.error("Failed to load schedules", error); } @@ -70,6 +128,19 @@ const SchedulePage = () => { // setSchedules(dummySchedules); }, []); + useEffect(() => { + const newColorMap = new Map(); + schedules.forEach((schedule) => { + if (!newColorMap.has(schedule.title)) { + newColorMap.set( + schedule.title, + colorClasses[newColorMap.size % colorClasses.length] + ); + } + }); + setTitleColorMap(newColorMap); + }, [schedules]); + const handleSlotClick = async (timeIdx) => { if (!isEditMode) return; @@ -206,13 +277,74 @@ const SchedulePage = () => { } }; + const getColorForTitle = (title) => { + if (!titleColorMap.has(title)) { + const newColor = colorClasses[titleColorMap.size % colorClasses.length]; + setTitleColorMap((prev) => new Map(prev).set(title, newColor)); + return newColor; + } + return titleColorMap.get(title); + }; + + const convertIndexToTime = (timeIndex) => { + const dayIndex = Math.floor(timeIndex / 96); + const timeSlotIndex = timeIndex % 96; + const hour = Math.floor(timeSlotIndex / 4); + const minute = (timeSlotIndex % 4) * 15; + const day = days[dayIndex]; + const time = `${hour.toString().padStart(2, "0")}:${minute + .toString() + .padStart(2, "0")}`; + return `${day} ${time}`; + }; + + // 스케줄 통합해서 보여주기 + const renderTimeSlot = (slotIndex, rowIndex, colIndex) => { + const schedule = schedules.find((s) => s.time_indices.includes(slotIndex)); + const isSelected = selectedSlots.includes(slotIndex); + + const isFirstSlot = + schedule && + !schedule.time_indices.includes(slotIndex - 1) && + Math.floor((slotIndex - 1) / 96) === Math.floor(slotIndex / 96); + + const isLastSlot = + schedule && + !schedule.time_indices.includes(slotIndex + 1) && + Math.floor((slotIndex + 1) / 96) === Math.floor(slotIndex / 96); + + return ( + <div + key={slotIndex} + className={`p-2 border ${ + schedule + ? `${getColorForTitle(schedule.title)} text-white + ${!isFirstSlot && !isLastSlot ? "border-t-0 border-b-0" : ""} + ${!isFirstSlot ? "border-t-0" : ""} + ${!isLastSlot ? "border-b-0" : ""}` + : isSelected + ? "bg-primary-100 border-primary-300" + : "bg-grayscale-50" + } cursor-pointer`} + onClick={() => handleSlotClick(slotIndex)} + > + {isFirstSlot ? schedule?.title : ""} + </div> + ); + }; + + const filterTimeSlots = (time) => { + const hour = parseInt(time.split(":")[0]); + return showAllTimeSlot || (hour >= 8 && hour <= 18); + }; + return ( <div className="min-h-screen bg-grayscale-50"> {/* Toggle View/Edit Mode */} <div className="flex items-center justify-between p-4 bg-white shadow"> - <h1 className="heading-1">Schedule</h1> + <h1 className="heading-2">내 시간표</h1> <label className="flex items-center space-x-3 cursor-pointer"> - <span className="title-1 text-primary-500">Edit Mode</span> + <span className="title-1 text-primary-500">수정 모드</span> <div className={`relative w-12 h-6 rounded-full transition-colors ${ isEditMode ? "bg-primary-500" : "bg-grayscale-300" @@ -232,12 +364,59 @@ const SchedulePage = () => { </label> </div> + {/* 더보기 버튼 */} + <div className="flex justify-center mt-4"> + <Button + theme="white" + size="sm" + className="w-full mx-4 hover:bg-grayscale-50" + onClick={() => setShowAllTimeSlot(!showAllTimeSlot)} + > + {showAllTimeSlot ? "시간 접기" : "전체 시간 보기"} + </Button> + </div> + + {/* Schedule Grid */} + <div className="p-4 pb-[210px]"> + <div className="overflow-auto scrollbar-hide"> + <div className="w-[100vw] tablet:w-[960px] grid grid-cols-[64px,repeat(7,1fr)] gap-0"> + {/* Header */} + <div className="min-w-[54px] p-2 font-bold text-center bg-grayscale-200 select-none"> + Time + </div> + {days.map((day) => ( + <div + key={day} + className="p-2 font-bold text-center select-none bg-grayscale-200" + > + {day} + </div> + ))} + + {/* Time Slots */} + {timeSlots.map((time, rowIndex) => { + if (!filterTimeSlots(time)) return null; + + return ( + <React.Fragment key={rowIndex}> + <div className="min-w-[54px] p-2 font-bold text-center bg-grayscale-100 select-none"> + {time} + </div> + {days.map((_, colIndex) => { + const slotIndex = colIndex * timeSlots.length + rowIndex; + return renderTimeSlot(slotIndex, rowIndex, colIndex); + })} + </React.Fragment> + ); + })} + </div> + </div> + </div> + {/* Sticky Container in Edit Mode */} {isEditMode && ( - <div className="fixed bottom-0 right-0 flex items-center justify-center w-full "> - <div - className={`transform transition-transform w-full max-w-[768px] tablet:rounded-2xl bg-primary-100 p-6 text-center shadow-lg`} - > + <div className="fixed bottom-0 right-0 z-10 flex items-center justify-center w-full"> + <div className="transform transition-transform w-full max-w-[768px] tablet:rounded-2xl bg-primary-100/90 backdrop-blur-sm p-6 text-center shadow-lg"> {selectedSlots.length === 0 && selectedSchedule ? ( <div className="flex flex-col items-center justify-center w-full"> <h3 className="mb-2 heading-2 text-primary-500">스케줄 정보</h3> @@ -253,7 +432,7 @@ const SchedulePage = () => { <strong>선택된 시간:</strong>{" "} {selectedSchedule.time_indices.map((time_idx) => ( <Label key={time_idx} theme="indigo" size="sm"> - {time_idx} + {convertIndexToTime(time_idx)} </Label> ))} </div> @@ -288,36 +467,51 @@ const SchedulePage = () => { type="text" value={newTitle} onChange={(e) => setNewTitle(e.target.value)} - placeholder="Enter title" - className="w-full p-2 mb-4 border rounded shadow-input-box" + placeholder="스케줄 제목" + className="w-full p-2 px-4 mb-4 border rounded-full shadow-input-box" /> <div className="flex items-center justify-center mb-4 space-x-4"> - <label className="flex items-center space-x-2"> - <input - type="radio" - name="is_fixed" - value={true} - checked={isFixed === true} - onChange={() => setIsFixed(true)} - /> - <span className="body-1">고정 스케줄</span> - </label> - <label className="flex items-center space-x-2"> - <input - type="radio" - name="is_fixed" - value={false} - checked={isFixed === false} - onChange={() => setIsFixed(false)} - /> - <span className="body-1">유동 스케줄</span> - </label> + <div className="flex items-center space-x-4"> + <label className="flex items-center cursor-pointer"> + <input + type="radio" + name="is_fixed" + value={true} + checked={isFixed === true} + onChange={() => setIsFixed(true)} + className="hidden peer" + /> + <div className="flex items-center justify-center w-5 h-5 border-2 border-gray-400 rounded-full peer-checked:border-tertiary-500 peer-checked:bg-tertiary-500"> + <div className="w-2.5 h-2.5 bg-white rounded-full peer-checked:bg-white"></div> + </div> + <span className="ml-2 text-sm font-medium peer-checked:text-tertiary-500"> + 고정 스케줄 + </span> + </label> + + <label className="flex items-center cursor-pointer"> + <input + type="radio" + name="is_fixed" + value={false} + checked={isFixed === false} + onChange={() => setIsFixed(false)} + className="hidden peer" + /> + <div className="flex items-center justify-center w-5 h-5 border-2 border-gray-400 rounded-full peer-checked:border-primary-500 peer-checked:bg-primary-500"> + <div className="w-2.5 h-2.5 bg-white rounded-full peer-checked:bg-white"></div> + </div> + <span className="ml-2 text-sm font-medium peer-checked:text-primary-500"> + 유동 스케줄 + </span> + </label> + </div> </div> - <div className="mb-4 body-1"> - <span>선택된 시간:</span> + <span className="heading-4">선택된 시간</span> + <div className="flex flex-wrap gap-1 p-2 m-2 border rounded-lg body-1 border-primary-500"> {selectedSlots.map((time_idx) => ( - <Label key={time_idx} theme="indigo" size="sm"> - {time_idx} + <Label key={time_idx} theme="solid" size="sm"> + {convertIndexToTime(time_idx)} </Label> ))} </div> @@ -329,19 +523,23 @@ const SchedulePage = () => { 수정 완료 </button> ) : ( - <div className="flex justify-center mt-4 space-x-4"> - <button - className="px-4 py-2 font-bold text-white rounded bg-tertiary-900" + <div className="flex justify-center w-full mt-4 space-x-2"> + <Button + theme="indigo" + size="md" + className="flex-1" onClick={() => handleCancelSchedule()} > 취소 - </button> - <button - className="px-4 py-2 font-bold text-white rounded bg-gradient-pink" + </Button> + <Button + theme="pink" + size="md" + className="flex-1" onClick={() => handleCreateSchedule()} > 추가 - </button> + </Button> </div> )} </> @@ -349,59 +547,6 @@ const SchedulePage = () => { </div> </div> )} - - {/* Schedule Grid */} - <div className="p-4"> - <div className="overflow-auto scrollbar-hide"> - <div className="w-[100vw] tablet:w-[960px] grid grid-cols-[64px,repeat(7,1fr)] gap-2"> - {/* Header */} - <div className="min-w-[54px] p-2 font-bold text-center bg-grayscale-200 select-none"> - Time - </div> - {days.map((day) => ( - <div - key={day} - className="p-2 font-bold text-center select-none bg-grayscale-200" - > - {day} - </div> - ))} - - {/* Time Slots */} - {timeSlots.map((time, rowIndex) => ( - <React.Fragment key={rowIndex}> - {/* Time Column */} - <div className="min-w-[54px] p-2 font-bold text-center bg-grayscale-100 select-none"> - {time} - </div> - {days.map((_, colIndex) => { - const slotIndex = colIndex * timeSlots.length + rowIndex; - const isSelected = selectedSlots.includes(slotIndex); - const schedule = schedules.find((s) => - s.time_indices.includes(slotIndex) - ); - - return ( - <div - key={slotIndex} - className={`p-2 border rounded ${ - schedule - ? "bg-primary-300 text-white cursor-not-allowed" - : isSelected - ? "bg-primary-100 border-primary-300" - : "bg-grayscale-50 cursor-pointer" - }`} - onClick={() => handleSlotClick(slotIndex)} - > - {schedule ? schedule.title : ""} - </div> - ); - })} - </React.Fragment> - ))} - </div> - </div> - </div> </div> ); }; -- GitLab From 10c5b0d8ae9ec00c8c0660118a04a7524479629b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 21:21:43 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20fcm=20=ED=86=A0=ED=81=B0=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/api/auth.js b/src/api/auth.js index df4042f..fe57b12 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -5,7 +5,16 @@ * @returns {string} 로그인 엔드포인트 URL */ export const getLoginUrl = () => { - return `${process.env.REACT_APP_BASE_URL}/api/auth/login`; + const baseUrl = process.env.REACT_APP_BASE_URL; + const fcmToken = localStorage.getItem("fcmToken"); + + // fcmToken이 있을 경우 파라미터에 추가 + const params = new URLSearchParams(); + if (fcmToken) { + params.append("fcmToken", fcmToken); + } + + return `${baseUrl}/api/auth/login?${params.toString()}`; }; /** -- GitLab From 51bcb25ef0c4f5c517c1887dd24d225f63412f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=B0=AC=20=EC=9C=A4?= <ysc0731@ajou.ac.kr> Date: Sun, 8 Dec 2024 21:22:19 +0900 Subject: [PATCH 13/13] =?UTF-8?q?feat:=20404NotFoundPage=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B0=9C=EB=B0=9C=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.js | 2 ++ src/pages/NotFoundPage.jsx | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/pages/NotFoundPage.jsx diff --git a/src/App.js b/src/App.js index 5cc4e04..ced6082 100644 --- a/src/App.js +++ b/src/App.js @@ -11,6 +11,7 @@ import Footer from "./components/layout/Footer"; import BodyLayout from "./components/layout/BodyLayout"; import HeaderLogoBar from "./components/layout/HeaderLogoBar"; import SchedulePage from "./pages/SchedulePage"; +import NotFoundPage from "./pages/NotFoundPage"; const App = () => { return ( @@ -29,6 +30,7 @@ const App = () => { /> <Route path="/mypage" element={<MyPage />} /> <Route path="/login" element={<LoginPage />} /> + <Route path="*" element={<NotFoundPage />} /> </Routes> </BodyLayout> <Footer /> diff --git a/src/pages/NotFoundPage.jsx b/src/pages/NotFoundPage.jsx new file mode 100644 index 0000000..1612743 --- /dev/null +++ b/src/pages/NotFoundPage.jsx @@ -0,0 +1,28 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Button from "../components/Button"; // 기존 디자인 시스템의 버튼 컴포넌트 + +const NotFoundPage = () => { + const navigate = useNavigate(); + + const handleGoHome = () => { + navigate("/"); + }; + + return ( + <div className="flex flex-col items-center justify-center min-h-screen text-center bg-gray-50"> + <h1 className="text-6xl font-bold text-primary-500">404</h1> + <p className="mt-4 text-lg text-gray-700"> + 페이지를 찾을 수 없습니다. 잘못된 URL을 입력했거나 페이지가 삭제되었을 + 수 있습니다. + </p> + <div className="mt-6"> + <Button size="lg" theme="indigo" onClick={handleGoHome}> + 홈으로 이동 + </Button> + </div> + </div> + ); +}; + +export default NotFoundPage; -- GitLab