diff --git a/README.md b/README.md index 0c8136bc..6be34173 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # SSOUL 프로젝트 인수인계 문서 -## �� 프로젝트 개요 +## 프로젝트 개요 **프로젝트명**: SSOUL (칵테일을 좋아하는 사람들을 위한 서비스) **기술 스택**: Next.js 15, React 19, TypeScript, Tailwind CSS **저장소**: https://github.com/prgrms-web-devcourse-final-project/WEB5_6_HaeDokCoding_FE -## �� 시작하기 +## 시작하기 ### 개발 환경 설정 ```bash @@ -60,7 +60,7 @@ src/ └── utills/ # 유틸리티 함수 ``` -## �� 주요 기능 +## 주요 기능 ### 1. 인증 시스템 - **소셜 로그인**: Google, Kakao, Naver 지원 @@ -72,11 +72,11 @@ src/ ### 2. 페이지별 기능 -#### �� 메인 페이지 (`/`) +#### 메인 페이지 (`/`) - 현재 기본 구조만 구현됨 - 추후 확장 예정 -#### �� 칵테일 레시피 (`/recipe`) +#### 칵테일 레시피 (`/recipe`) - **주요 컴포넌트**: - `CocktailList`: 칵테일 목록 표시 - `Accordion`: 필터링 옵션 @@ -84,7 +84,7 @@ src/ - **기능**: 검색, 필터링, 정렬 - **상세 페이지**: `/recipe/[id]` - 개별 칵테일 상세 정보 -#### �� 커뮤니티 (`/community`) +#### 커뮤니티 (`/community`) - **주요 컴포넌트**: - `PostCard`: 게시물 카드 - `CommunityTab`: 카테고리 탭 @@ -93,7 +93,7 @@ src/ - **글쓰기**: `/community/write` - **상세 페이지**: `/community/[id]` -#### �� 취향 추천 (`/recommend`) +#### 취향 추천 (`/recommend`) - **챗봇 기반 추천**: `ChatSection` 컴포넌트 - **주요 컴포넌트**: - `BotMessage`, `UserMessage`: 메시지 컴포넌트 @@ -101,7 +101,7 @@ src/ - `MessageInput`: 입력창 - `TypingIndicator`: 타이핑 효과 -#### �� 마이페이지 (`/mypage`) +#### 마이페이지 (`/mypage`) - **기본 리다이렉트**: `/mypage` → `/mypage/mybar` - **주요 섹션**: - `/mypage/mybar`: 나만의 바 @@ -109,12 +109,12 @@ src/ - `/mypage/my-alarm`: 알림 설정 - `/mypage/my-setting`: 계정 설정 -#### �� 로그인 (`/login`) +#### 로그인 (`/login`) - **소셜 로그인**: `SocialLogin` 컴포넌트 - **성공 페이지**: `/login/success` - **신규 사용자**: `/login/user/first-user` -## �� 기술적 세부사항 +## 기술적 세부사항 ### 상태 관리 - **Zustand**: 클라이언트 상태 관리 @@ -136,28 +136,28 @@ src/ - **Husky**: Git hooks - **Lint-staged**: 커밋 전 검사 -## �� 주요 설정 파일 +## 주요 설정 파일 - `next.config.ts`: Next.js 설정 - `tailwind.config.js`: Tailwind CSS 설정 - `eslint.config.mjs`: ESLint 설정 - `tsconfig.json`: TypeScript 설정 -## �� 반응형 디자인 +## 반응형 디자인 프로젝트는 모바일 우선(Mobile-first) 접근 방식을 사용합니다: - **모바일**: 기본 스타일 - **태블릿**: `md:` prefix - **데스크톱**: `lg:`, `xl:` prefix -## �� 주의사항 +## 주의사항 1. **환경 변수**: 개발/운영 환경에 맞는 API URL 설정 필요 2. **인증 토큰**: localStorage에 저장되므로 보안 고려 필요 3. **API 통신**: `credentials: 'include'` 설정으로 쿠키 기반 인증 4. **Git Hooks**: Husky 설정으로 커밋 전 자동 검사 -## �� 추가 문의 +## 추가 문의 - **저장소 이슈**: https://github.com/prgrms-web-devcourse-final-project/WEB5_6_HaeDokCoding_FE/issues - **주요 브랜치**: `main` (메인), `dev` (개발) @@ -165,5 +165,5 @@ src/ --- **작성일**: 2025-10-14 -**작성자**: 이성헌 +**작성자**: 정은빈 | 김아현 | 문태민 **버전**: 1.0 \ No newline at end of file diff --git a/src/app/(no-layout)/layout.tsx b/src/app/(no-layout)/layout.tsx deleted file mode 100644 index 79bf3c19..00000000 --- a/src/app/(no-layout)/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import FooterWrapper from '@/shared/components/footer/FooterWrapper'; -import Header from '@/shared/components/header/Header'; - -function NoLayout({ children }: { children: React.ReactNode }) { - return ( - <> - - {children} - - > - ); -} -export default NoLayout; diff --git a/src/app/(main)/design-system/page.tsx b/src/app/(with-layout)/design-system/page.tsx similarity index 100% rename from src/app/(main)/design-system/page.tsx rename to src/app/(with-layout)/design-system/page.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5c6374ce..57942c72 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ import '@/shared/styles/global.css'; import type { Metadata } from 'next'; -import '@/shared/styles/global.css'; import { Toaster } from 'react-hot-toast'; import ScrollTopBtnWrapper from '@/shared/components/scroll-top/ScrollTopBtnWrapper'; import KaKaoScript from './api/kakao/KaKaoScript'; diff --git a/src/domains/community/api/fetchPost.ts b/src/domains/community/api/fetchPost.ts index f69ff24d..1ca87708 100644 --- a/src/domains/community/api/fetchPost.ts +++ b/src/domains/community/api/fetchPost.ts @@ -50,8 +50,8 @@ export const fetchPostByTab = async ({ const params = new URLSearchParams(); if (category && category !== 'all') { - const categoryId = tabItem.findIndex((tab) => tab.key === category); - if (categoryId >= 0) { + const categoryId = tabItem.findIndex((tab) => tab.key === category) + 1; + if (categoryId > 0) { params.set('categoryId', categoryId.toString()); } } diff --git a/src/domains/community/write/WriteSection.tsx b/src/domains/community/write/WriteSection.tsx index 4395e644..5e5dfe16 100644 --- a/src/domains/community/write/WriteSection.tsx +++ b/src/domains/community/write/WriteSection.tsx @@ -126,7 +126,7 @@ function WriteSection({ mode, postId }: Props) { return; } - const categoryId = tabItem.findIndex((tab) => tab.label === formData.categoryName); + const categoryId = tabItem.findIndex((tab) => tab.label === formData.categoryName) + 1; if (categoryId === -1) { toastError('카테고리를 선택해주세요.'); @@ -216,7 +216,7 @@ function WriteSection({ mode, postId }: Props) { return false; } - const categoryId = tabItem.findIndex((tab) => tab.label === formData.categoryName); + const categoryId = tabItem.findIndex((tab) => tab.label === formData.categoryName) + 1; if (categoryId === -1) { toastError('카테고리를 선택해주세요.'); return false; diff --git a/src/domains/main/api/useSSENotification.ts b/src/domains/main/api/useSSENotification.ts new file mode 100644 index 00000000..6cd2adf4 --- /dev/null +++ b/src/domains/main/api/useSSENotification.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react'; +import { getApi } from '@/app/api/config/appConfig'; + +export function useSSENotification(isLoggedIn: boolean) { + const [hasNewNotification, setHasNewNotification] = useState(false); + const eventSourceRef = useRef(null); + const isConnectingRef = useRef(false); + + useEffect(() => { + // 로그인 안 했으면 연결 안 함 + if (!isLoggedIn) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + isConnectingRef.current = false; + } + return; + } + + // 이미 연결 중이거나 연결되어 있으면 중복 방지 + if (isConnectingRef.current || eventSourceRef.current) { + return; + } + + isConnectingRef.current = true; + + const eventSource = new EventSource(`${getApi}/me/subscribe`, { + withCredentials: true, + }); + + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + isConnectingRef.current = false; + }; + + eventSource.onmessage = () => { + setHasNewNotification(true); + }; + + eventSource.onerror = () => { + isConnectingRef.current = false; + + if (eventSource.readyState === EventSource.CLOSED) { + eventSourceRef.current = null; + } + }; + + // cleanup 함수 + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + isConnectingRef.current = false; + }; + }, [isLoggedIn]); // isLoggedIn만 의존성으로 + + const clearNotification = () => { + setHasNewNotification(false); + }; + + return { hasNewNotification, clearNotification }; +} diff --git a/src/domains/main/cocktailDrop/CocktailDrop.tsx b/src/domains/main/cocktailDrop/CocktailDrop.tsx index 8c399ea0..ad9f71bb 100644 --- a/src/domains/main/cocktailDrop/CocktailDrop.tsx +++ b/src/domains/main/cocktailDrop/CocktailDrop.tsx @@ -45,6 +45,7 @@ function CocktailDrop({ isDesktop = false }: CocktailDropProps) { // 로고 위에서 아래로 자연스럽게 등장 const screenWidth = window.innerWidth; + const viewportHeight = window.innerHeight; const isTablet = screenWidth >= 640 && screenWidth < 1024; const isMobile = screenWidth < 640; @@ -54,7 +55,7 @@ function CocktailDrop({ isDesktop = false }: CocktailDropProps) { ? `-${viewportHeight * 0.3}px` : isTablet ? `-${viewportHeight * -0.8}px` - : '0px'; + : '210px'; gsap.fromTo( logoRef.current, @@ -87,16 +88,16 @@ function CocktailDrop({ isDesktop = false }: CocktailDropProps) { {/* 대각선 줄 1 */} {/* 대각선 줄 2 */} {/* 로고 */} - + ) : ( - - 어떤 칵테일이 끌리시나요? SSoul이 쉽게 골라드릴게요. + + 어떤 칵테일이 끌리시나요? SSoul이 쉽게 골라드릴게요. )} > diff --git a/src/domains/main/components/mainSlide/components/mobile/MobileAbv.tsx b/src/domains/main/components/mainSlide/components/mobile/MobileAbv.tsx index f532493e..f9a4ba0d 100644 --- a/src/domains/main/components/mainSlide/components/mobile/MobileAbv.tsx +++ b/src/domains/main/components/mainSlide/components/mobile/MobileAbv.tsx @@ -53,7 +53,10 @@ function MobileAbv() { 내 알콜도수 UP setIsClick(!isClick)} > diff --git a/src/domains/main/components/mainSlide/components/mobile/MobileSlideCommunity.tsx b/src/domains/main/components/mainSlide/components/mobile/MobileSlideCommunity.tsx index b8756ab6..928ed838 100644 --- a/src/domains/main/components/mainSlide/components/mobile/MobileSlideCommunity.tsx +++ b/src/domains/main/components/mainSlide/components/mobile/MobileSlideCommunity.tsx @@ -16,7 +16,10 @@ function MobileSlideCommunity() { 함께 나누는 칵테일 이야기 setIsClick(!isClick)} > diff --git a/src/domains/main/components/mainSlide/components/mobile/MobileSlideTest.tsx b/src/domains/main/components/mainSlide/components/mobile/MobileSlideTest.tsx index d326bbc0..77b4efb4 100644 --- a/src/domains/main/components/mainSlide/components/mobile/MobileSlideTest.tsx +++ b/src/domains/main/components/mainSlide/components/mobile/MobileSlideTest.tsx @@ -25,10 +25,15 @@ function MobileSlideTest() { AI기반 취향테스트 setIsClick(!isClick)} + className={clsx( + `block duration-300 z-1 sm:hidden`, + isClick ? 'rotate-[135deg]' : 'rotate-0' + )} + onClick={() => { + setIsClick(!isClick); + }} > - + { - console.log(myLike); - }, [myLike]); - - return ( - - {myLike.length > 0 ? ( - - ) : ( - 아직 좋아요를 누른 글이 없습니다 - )} - - ); + return ; } export default MyLike; diff --git a/src/domains/shared/components/comment/CommentList.tsx b/src/domains/shared/components/comment/CommentList.tsx index a2e7a658..079a1368 100644 --- a/src/domains/shared/components/comment/CommentList.tsx +++ b/src/domains/shared/components/comment/CommentList.tsx @@ -67,7 +67,6 @@ function CommentList({ aria-label="댓글 목록" className="flex flex-col mt-6 overflow-y-auto no-scrollbar" ref={parentRef} - style={{ minHeight: '300px', maxHeight: '600px' }} > {comments?.map((comment, index) => { diff --git a/src/shared/components/header/HeaderBtn.tsx b/src/shared/components/header/HeaderBtn.tsx index e86cb7e6..d0eb2848 100644 --- a/src/shared/components/header/HeaderBtn.tsx +++ b/src/shared/components/header/HeaderBtn.tsx @@ -8,19 +8,26 @@ import { useAuthStore } from '@/domains/shared/store/auth'; import { setPreLoginPath } from '@/domains/shared/auth/utils/setPreLoginPath'; import { useState } from 'react'; import LogoutConfirm from '@/domains/login/components/LogoutConfirm'; +import { useSSENotification } from '@/domains/main/api/useSSENotification'; function HeaderBtn({ pathname }: { pathname: string }) { const { isLoggedIn } = useAuthStore(); const router = useRouter(); const [logoutModalOpen, setLogoutModalOpen] = useState(false); + const { hasNewNotification, clearNotification } = useSSENotification(isLoggedIn); + const navButtons = [ { icon: Bell, label: '알림', className: pathname === '/mypage/my-alarm' ? 'text-tertiary' : 'text-current', hiddenMobile: true, - onClick: () => router.push('/mypage/my-alarm'), + onClick: () => { + clearNotification(); + router.push('/mypage/my-alarm'); + }, + showBadge: true, }, { icon: User, @@ -28,6 +35,7 @@ function HeaderBtn({ pathname }: { pathname: string }) { className: pathname === '/mypage' ? 'text-tertiary' : 'text-current', hiddenMobile: true, onClick: () => router.push('/mypage'), + showBadge: false, }, ]; @@ -50,7 +58,7 @@ function HeaderBtn({ pathname }: { pathname: string }) { {/* 아이콘 버튼들 */} {isLoggedIn && - navButtons.map(({ icon: Icon, label, onClick, className, hiddenMobile }) => ( + navButtons.map(({ icon: Icon, label, onClick, className, hiddenMobile, showBadge }) => ( + {showBadge && hasNewNotification && ( + + )} ))}
- 어떤 칵테일이 끌리시나요? SSoul이 쉽게 골라드릴게요. +
+ 어떤 칵테일이 끌리시나요? SSoul이 쉽게 골라드릴게요.