diff --git a/package-lock.json b/package-lock.json index ae33798..ee1a1a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,17 @@ "@lukemorales/query-key-factory": "^1.3.4", "@tanstack/react-query": "^5.76.1", "@tanstack/react-query-devtools": "^5.76.1", + "add": "^2.0.6", "axios": "^1.9.0", + "chart.js": "^4.5.0", + "chartjs-plugin-datalabels": "^2.2.0", "clsx": "^2.1.1", + "lodash": "^4.17.21", "lodash.throttle": "^4.1.1", "path": "^0.12.7", "prettier": "^3.5.3", "react": "^19.1.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-router-dom": "^7.6.0", @@ -32,6 +37,7 @@ "@commitlint/config-conventional": "^19.8.1", "@eslint/js": "^9.27.0", "@tailwindcss/vite": "^4.1.7", + "@types/lodash": "^4.17.20", "@types/lodash.throttle": "^4.1.9", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -1047,6 +1053,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lukemorales/query-key-factory": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@lukemorales/query-key-factory/-/query-key-factory-1.3.4.tgz", @@ -2015,6 +2027,12 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/add": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/add/-/add-2.0.6.tgz", + "integrity": "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==", + "license": "MIT" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2533,6 +2551,27 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4074,9 +4113,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5300,6 +5339,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -6200,6 +6245,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/src/api/auth/auth.ts b/src/api/auth/auth.ts index 3eeef26..48f6d9a 100644 --- a/src/api/auth/auth.ts +++ b/src/api/auth/auth.ts @@ -11,7 +11,7 @@ import type { TSignupValues, TSocialLoginResponse, TSocialLoginValues, -} from '@/types/auth'; +} from '@/types/auth/auth'; import { axiosInstance } from '../axiosInstance'; diff --git a/src/api/course/course.ts b/src/api/course/course.ts index 972654f..fa176f3 100644 --- a/src/api/course/course.ts +++ b/src/api/course/course.ts @@ -1,4 +1,4 @@ -import type { TSearchRegionResponse, TSearchRegionValues } from '@/types/dateCourse'; +import type { TSearchRegionResponse, TSearchRegionValues } from '@/types/dateCourse/dateCourse'; import { axiosInstance } from '../axiosInstance'; diff --git a/src/api/notice/notice.ts b/src/api/notice/notice.ts new file mode 100644 index 0000000..2249296 --- /dev/null +++ b/src/api/notice/notice.ts @@ -0,0 +1,25 @@ +import type { TFetchNoticeDetailResponse, TFetchNoticesResponse } from '@/types/notice/notice'; + +import { axiosInstance } from '../axiosInstance'; + +// 공지사항 전체 조회 API +export const fetchNotices = async ({ + category, + page, + size, +}: { + category: 'SERVICE' | 'SYSTEM'; + page: number; + size: number; +}): Promise => { + const { data } = await axiosInstance.get('/api/v1/notices', { + params: { noticeCategory: category, page, size }, + }); + return data; +}; + +// 공지사항 상세 조회 API +export const fetchNoticeDetail = async (noticeId: number): Promise => { + const { data } = await axiosInstance.get(`/api/v1/notices/${noticeId}`); + return data; +}; diff --git a/src/assets/icons/Burger_fill.svg b/src/assets/icons/Burger_fill.svg new file mode 100644 index 0000000..5fbcfde --- /dev/null +++ b/src/assets/icons/Burger_fill.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/Clear.svg b/src/assets/icons/Clear.svg new file mode 100644 index 0000000..c3b7aa7 --- /dev/null +++ b/src/assets/icons/Clear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/dateCourse/dateCourseOptionButton.tsx b/src/components/dateCourse/dateCourseOptionButton.tsx index 8511a09..5ecbe4f 100644 --- a/src/components/dateCourse/dateCourseOptionButton.tsx +++ b/src/components/dateCourse/dateCourseOptionButton.tsx @@ -1,4 +1,4 @@ -import type { TDateCourseOptionButtonProps } from '@/types/dateCourse'; +import type { TDateCourseOptionButtonProps } from '@/types/dateCourse/dateCourse'; export default function DateCourseOptionButton({ option, isSelected, onClick }: TDateCourseOptionButtonProps) { return ( diff --git a/src/components/dateCourse/dateCourseSearchFilterOption.tsx b/src/components/dateCourse/dateCourseSearchFilterOption.tsx index 348f15d..38b8c80 100644 --- a/src/components/dateCourse/dateCourseSearchFilterOption.tsx +++ b/src/components/dateCourse/dateCourseSearchFilterOption.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; -import type { TDateCourseSearchFilterOption } from '@/types/dateCourse'; +import type { TDateCourseSearchFilterOption, TRegion } from '@/types/dateCourse/dateCourse'; import DATE_KEYWORD from '@/constants/dateKeywords'; import { useSearchRegion } from '@/hooks/course/useSearchRegion'; -import useDebounce from '@/hooks/useDebounce'; import DateCourseOptionButton from './dateCourseOptionButton'; import DateKeyword from './dateKeyword'; @@ -24,9 +23,6 @@ export default function DateCourseSearchFilterOption({ options, type, value, onC const [date, setDate] = useState(defaultDate); const [time, setTime] = useState(defaultTime); const [inputValue, setInputValue] = useState(''); - const [showSearchResults, setShowSearchResults] = useState(false); - - const debouncedInputValue = useDebounce(inputValue, 3000); useEffect(() => { onChange(`${date} ${time}`); @@ -50,11 +46,12 @@ export default function DateCourseSearchFilterOption({ options, type, value, onC setInputValue(e.target.value); }; - const { data: regionList } = useSearchRegion({ keyword: debouncedInputValue }); + const { data: regionList, refetch } = useSearchRegion({ keyword: inputValue }, { enabled: false }); const handleSearch = () => { - if (!inputValue.trim()) return; - setShowSearchResults(true); + const keyword = inputValue.trim(); + if (!keyword) return; + refetch(); }; return ( @@ -96,22 +93,21 @@ export default function DateCourseSearchFilterOption({ options, type, value, onC onChange={handleInputChange} /> - {showSearchResults && regionList && regionList.result.regions.length > 0 && ( + {regionList && regionList.result.regions.length > 0 && ( diff --git a/src/components/dateCourse/dateKeyword.tsx b/src/components/dateCourse/dateKeyword.tsx index 229678e..961556c 100644 --- a/src/components/dateCourse/dateKeyword.tsx +++ b/src/components/dateCourse/dateKeyword.tsx @@ -1,4 +1,4 @@ -import type { TDateKeyword } from '@/types/dateCourse'; +import type { TDateKeyword } from '@/types/dateCourse/dateCourse'; import KeywordButton from './keywordButton'; diff --git a/src/components/dateCourse/info.tsx b/src/components/dateCourse/info.tsx index e147100..a0bb6ec 100644 --- a/src/components/dateCourse/info.tsx +++ b/src/components/dateCourse/info.tsx @@ -1,4 +1,4 @@ -import type { TInfo } from '@/types/dateCourse'; +import type { TInfo } from '@/types/dateCourse/dateCourse'; import InfoElement from './infoElement'; diff --git a/src/components/dateCourse/keywordButton.tsx b/src/components/dateCourse/keywordButton.tsx index df17519..47613ef 100644 --- a/src/components/dateCourse/keywordButton.tsx +++ b/src/components/dateCourse/keywordButton.tsx @@ -1,4 +1,4 @@ -import type { TKeywordButtonProps } from '@/types/dateCourse'; +import type { TKeywordButtonProps } from '@/types/dateCourse/dateCourse'; export default function KeywordButton({ tag, selected = false, onClick, isButton }: TKeywordButtonProps) { return ( diff --git a/src/components/dateCourse/timeline.tsx b/src/components/dateCourse/timeline.tsx index bfe1c0b..1b986ce 100644 --- a/src/components/dateCourse/timeline.tsx +++ b/src/components/dateCourse/timeline.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import type { TTimeline } from '@/types/dateCourse'; +import type { TTimeline } from '@/types/dateCourse/dateCourse'; import KeywordButton from './keywordButton'; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index bd5e2ca..ccb6d17 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,39 +1,41 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; +import MobileMenu from './MobileMenu'; import SettingsModal from '../modal/SettingModal'; +import BurgerIcon from '@/assets/icons/Burger_fill.svg?react'; +import ClearIcon from '@/assets/icons/Clear.svg?react'; import NotificationsIcon from '@/assets/icons/notifications_Blank.svg?react'; import SettingsIcon from '@/assets/icons/settings_Blank.svg?react'; import NavbarLogo from '@/assets/withTimeLogo/navbarLogo.svg?react'; -// Header 컴포넌트 props interface IHeaderProps { mode?: 'full' | 'minimal'; // full: nav + border | minimal: 로고만 } export default function Header({ mode = 'full' }: IHeaderProps) { - const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); //설정 모달 + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // 모바일 메뉴 const showNav = mode === 'full'; const showBorder = mode === 'full'; return (
+ {/* 최상단 네브바 */}
{/* 로고 */} -
- - - -
+ + + - {/* Nav 그룹 */} + {/* 데스크탑 메뉴 */} {showNav && ( -
- {/* 메뉴 */} +
+ {/* 네비게이션 링크들 */} - {/* 아이콘 */} + {/* 아이콘 버튼 */}
@@ -61,12 +63,30 @@ export default function Header({ mode = 'full' }: IHeaderProps) {
+
+ )} - {/* 설정 모달 */} - {isSettingsOpen && setIsSettingsOpen(false)} />} + {/* 모바일 메뉴 토글 버튼 */} + {showNav && ( +
+ {!isMobileMenuOpen ? ( + + ) : ( + + )}
)}
+ + {/* 모바일 메뉴 */} + {isMobileMenuOpen && setIsMobileMenuOpen(false)} onOpenSettings={() => setIsSettingsOpen(true)} />} + + {/* 설정 모달 */} + {isSettingsOpen && setIsSettingsOpen(false)} />}
); } diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx new file mode 100644 index 0000000..6fe19e3 --- /dev/null +++ b/src/components/layout/MobileMenu.tsx @@ -0,0 +1,66 @@ +import { Link } from 'react-router-dom'; + +import ClearIcon from '@/assets/icons/Clear.svg?react'; +import NotificationsIcon from '@/assets/icons/notifications_Blank.svg?react'; +import SettingsIcon from '@/assets/icons/settings_Blank.svg?react'; + +interface IMobileMenuProps { + onClose: () => void; + onOpenSettings: () => void; +} + +export default function MobileMenu({ onClose, onOpenSettings }: IMobileMenuProps) { + return ( + <> + {/* 검정 반투명 배경 오버레이 */} +
+ + {/* 사이드 메뉴 */} +
+ {/* 닫기 버튼 */} +
+ +
+ + {/* 메뉴 목록 */} + +
+ + ); +} diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 2528da4..ce4c2e4 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -1,12 +1,8 @@ -// import { useNavigate } from 'react-router-dom'; - import { useCoreMutation } from '../customQuery'; import { checkEmailVerifications, defaultLogin, defaultSignup, emailVerifications, findPassword, logout, socialLogin } from '@/api/auth/auth'; export function useAuth() { - // const navigate = useNavigate(); - const useDefaultLogin = useCoreMutation(defaultLogin); const useDefaultSignup = useCoreMutation(defaultSignup); const useLogout = useCoreMutation(logout); diff --git a/src/hooks/course/useSearchRegion.tsx b/src/hooks/course/useSearchRegion.tsx index 919e621..4a7e07a 100644 --- a/src/hooks/course/useSearchRegion.tsx +++ b/src/hooks/course/useSearchRegion.tsx @@ -1,9 +1,11 @@ import { useCoreQuery } from '../customQuery'; import { searchRegion } from '@/api/course/course'; +import { regionKeys } from '@/queryKey/queryKey'; -export function useSearchRegion({ keyword }: { keyword: string }) { - return useCoreQuery(['searchRegion', keyword], () => searchRegion({ keyword }), { - enabled: !!keyword, +export function useSearchRegion({ keyword }: { keyword: string }, options?: { enabled?: boolean }) { + return useCoreQuery(regionKeys.search(keyword).queryKey, () => searchRegion({ keyword }), { + enabled: options?.enabled ?? true, + staleTime: 1000 * 60 * 5, }); } diff --git a/src/main.tsx b/src/main.tsx index 5e64818..9cf4a9b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,5 @@ import './index.css'; -import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -9,10 +8,8 @@ import { queryClient } from './api/queryClient.ts'; import App from './App.tsx'; createRoot(document.getElementById('root')!).render( - - - {import.meta.env.VITE_DEV_MODE && } - - - , + + {import.meta.env.VITE_DEV_MODE && } + + , ); diff --git a/src/pages/RedirectPage.tsx b/src/pages/RedirectPage.tsx index ce47333..024dc89 100644 --- a/src/pages/RedirectPage.tsx +++ b/src/pages/RedirectPage.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import type { TSocialLoginPlatform } from '@/types/auth'; +import type { TSocialLoginPlatform } from '@/types/auth/auth'; import { useAuth } from '@/hooks/auth/useAuth'; diff --git a/src/pages/auth/FindPw.tsx b/src/pages/auth/FindPw.tsx index 3ec7059..23522ee 100644 --- a/src/pages/auth/FindPw.tsx +++ b/src/pages/auth/FindPw.tsx @@ -4,7 +4,7 @@ import { useForm, useWatch } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { TFormValues } from '@/types/auth'; +import type { TFormValues } from '@/types/auth/auth'; import { findingSchema } from '@/utils/validation'; diff --git a/src/pages/auth/JoinPage.tsx b/src/pages/auth/JoinPage.tsx index 0e467fc..f32adc4 100644 --- a/src/pages/auth/JoinPage.tsx +++ b/src/pages/auth/JoinPage.tsx @@ -4,7 +4,7 @@ import { useForm, useWatch } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { TFormValues } from '@/types/auth'; +import type { TFormValues } from '@/types/auth/auth'; import { signupSchema } from '@/utils/validation'; diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 8769abd..d7db6cd 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -4,7 +4,7 @@ import { useForm, useWatch } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { TLoginFormValues } from '@/types/auth'; +import type { TLoginFormValues } from '@/types/auth/auth'; import { loginSchema } from '@/utils/validation'; diff --git a/src/pages/auth/RedirectPage.tsx b/src/pages/auth/RedirectPage.tsx index b21f430..6c39ab9 100644 --- a/src/pages/auth/RedirectPage.tsx +++ b/src/pages/auth/RedirectPage.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import type { TSocialLoginPlatform } from '@/types/auth'; +import type { TSocialLoginPlatform } from '@/types/auth/auth'; import { useAuth } from '@/hooks/auth/useAuth'; diff --git a/src/pages/auth/UserSetting.tsx b/src/pages/auth/UserSetting.tsx index bba43bd..22102e9 100644 --- a/src/pages/auth/UserSetting.tsx +++ b/src/pages/auth/UserSetting.tsx @@ -4,8 +4,8 @@ import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { TUserSettingFormValues } from '@/types/auth'; -import { Gender } from '@/types/auth'; +import type { TUserSettingFormValues } from '@/types/auth/auth'; +import { Gender } from '@/types/auth/auth'; import formatDateInput from '@/utils/formatDateInput'; import formatInputNumber from '@/utils/formatPhoneNumber'; diff --git a/src/pages/dateCourse/MakeCourseStep.tsx b/src/pages/dateCourse/MakeCourseStep.tsx index e7d09b2..f8e47a5 100644 --- a/src/pages/dateCourse/MakeCourseStep.tsx +++ b/src/pages/dateCourse/MakeCourseStep.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import type { IQuestion } from '@/types/dateCourse'; +import type { IQuestion } from '@/types/dateCourse/dateCourse'; import { DateCourseQuestion } from '@/constants/dateCourseQuestion'; import { diff --git a/src/pages/notice/Notice.tsx b/src/pages/notice/Notice.tsx index 1e7ae31..884070a 100644 --- a/src/pages/notice/Notice.tsx +++ b/src/pages/notice/Notice.tsx @@ -1,74 +1,61 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; +import type { TNoticeItem } from '@/types/notice/notice'; + import EditableInputBox from '@/components/common/EditableInputBox'; import Navigator from '@/components/common/navigator'; -const categories = ['서비스 안내', '시스템 안내']; +import { fetchNotices } from '@/api/notice/notice'; -const dummyNotices = [ - { category: '서비스 안내', title: '서비스 점검 안내 (06월 20일 02:00~04:00)', date: '2025.06.09' }, - { category: '서비스 안내', title: "신규 기능 '코스 저장하기' 오픈 안내", date: '2025.06.09' }, - { - category: '서비스 안내', - title: '데이트 추천 정확도 향상을 위한 업데이트 공지', - date: '2025.06.09', - content: `안녕하세요, WithTime 팀입니다. - -항상 WithTime을 이용해주시는 모든 사용자 여러분께 진심으로 감사드립니다. - 보다 더 정확하고 만족스러운 데이트 코스를 추천해드리기 위해,아래와 같은 기능 개선 및 시스템 업데이트를 진행하였음을 알려드립니다. - -🔧 주요 업데이트 내용 -1. 사용자 취향 기반 알고리즘 개선 -기존에는 간단한 지역 및 활동 선호도 중심으로 코스를 구성했다면, -이번 업데이트부터는 시간대, 최근 행동 패턴, 선택 취소된 장소 이력 등 -더 정밀한 데이터를 분석하여 추천의 정확도를 높였습니다. - -2. 상황별 추천 강화 -- 비 오는 날에는 실내 데이트 중심으로 -- 일정 시간이 짧을 경우, 이동 거리를 고려한 코스 구성 -이처럼 날씨, 이동 시간, 데이트 시간대를 함께 반영하도록 개선했습니다. - -3. 실시간 트렌드 반영 -주요 지역별 인기 급상승 장소나 SNS 상에서 언급된 핫플레이스 정보를 -추천 코스에 반영하여, 최신 트렌드를 더 빠르게 만나보실 수 있습니다. - -🎯 기대 효과 -- "오늘 뭐하지?" 고민할 시간 없이 상황 맞춤형 코스를 자동 추천 -- MBTI P 유형 사용자도 만족할 만큼 빠르고 간단한 코스 구성 -- 더 이상 ‘나랑 안 맞는 장소 추천’으로 불편하지 않도록 개선 - -📅 적용 일시 -- 2025년 6월 21일(금) 00:00부터 순차 적용 예정입니다. - -이번 업데이트는 사용자 여러분의 피드백을 바탕으로 진행되었습니다. -앞으로도 더 나은 서비스 제공을 위해 지속적으로 개선해나가겠습니다. -사용 중 불편한 점이나 건의 사항이 있다면, 언제든지 고객센터 또는 [문의하기]를 통해 알려주세요. -감사합니다. - -WithTime 팀 드림`, - }, - { category: '서비스 안내', title: '비회원 기능 이용 제한 관련 안내', date: '2025.06.09' }, - { category: '서비스 안내', title: '지도 기반 추천 기능 일시 중단 안내', date: '2025.06.09' }, - { category: '서비스 안내', title: '지도 기반 추천 기능 일시 중단 안내', date: '2025.06.09' }, - { category: '서비스 안내', title: '지도 기반 추천 기능 일시 중단 안내', date: '2025.06.09' }, - { category: '시스템 안내', title: '일부 브라우저에서 발생하는 오류 관련 안내', date: '2025.06.09' }, - { category: '시스템 안내', title: '추천 코스 반영 기준 변경 안내', date: '2025.06.09' }, - { category: '시스템 안내', title: '회원가입 약관 일부 변경 안내', date: '2025.06.09' }, -]; +const categories = ['서비스 안내', '시스템 안내']; export default function Notice() { - const [searchValue, setSearchValue] = useState(''); - const [activeCategory, setActiveCategory] = useState(categories[0]); + const [searchValue, setSearchValue] = useState(''); //검색어 상태 + const [activeCategory, setActiveCategory] = useState(categories[0]); //선택된 카테고리 const [currentPage, setCurrentPage] = useState(1); + + const [noticeList, setNoticeList] = useState([]); // 공지사항 리스트 + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const itemsPerPage = 10; - // 필터링 + 페이징 - const filteredNotices = dummyNotices.filter( - (notice) => notice.category === activeCategory && notice.title.toLowerCase().includes(searchValue.toLowerCase()), - ); - const totalPages = Math.ceil(filteredNotices.length / itemsPerPage); - const paginatedNotices = filteredNotices.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); + // 백엔드에 넘길 카테고리 키 - 영어 변환 + const categoryKey = activeCategory === '서비스 안내' ? 'SERVICE' : 'SYSTEM'; + + // 컴포넌트 마운트 시, 카테고리/페이지 변경 시 -> API 호출 + useEffect(() => { + const getNotices = async () => { + setLoading(true); + try { + // 공지사항 목록 요청 + const response = await fetchNotices({ + category: categoryKey, + page: currentPage - 1, + size: itemsPerPage, + }); + + console.log('API 응답:', response); + + // 공지 목록과 페이지 수 설정 (빈 배열도 허용) + setNoticeList(response.result.noticeList ?? []); + setTotalPages(response.result.totalPages ?? 1); + } catch (err) { + // 오류 처리 + setError('공지사항을 불러오는 데 실패했습니다.'); + console.log(err); + } finally { + setLoading(false); + } + }; + + getNotices(); // 함수 실행 + }, [activeCategory, currentPage]); // 의존성 배열 - 카테고리/페이지 변경 시마다 재호출 + + // 검색어 필터링 적용된 공지사항 + const filteredNotices = noticeList.filter((notice) => notice.title.toLowerCase().includes(searchValue.toLowerCase())); return (
@@ -102,15 +89,16 @@ export default function Notice() { ))}
+ {/* 공지 없을 때 메시지 */} + {!loading && !error && filteredNotices.length === 0 &&

공지사항이 없습니다.

} + {/* 공지 리스트 */}
    - {paginatedNotices.map((notice, index) => ( -
  • - -
    - {notice.title} -
    - {notice.date} + {filteredNotices.map((notice) => ( +
  • + + {notice.title} + {new Date(notice.createdAt).toLocaleDateString()}
  • ))} @@ -122,7 +110,7 @@ export default function Notice() { current={currentPage} end={totalPages} onClick={(page) => { - setCurrentPage(page); + setCurrentPage(page); //페이지 변경 }} /> )} diff --git a/src/pages/notice/NoticeDetail.tsx b/src/pages/notice/NoticeDetail.tsx index c1c9a96..a2020a0 100644 --- a/src/pages/notice/NoticeDetail.tsx +++ b/src/pages/notice/NoticeDetail.tsx @@ -1,9 +1,48 @@ +import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import type { TNoticeDetail } from '@/types/notice/notice'; + +import { fetchNoticeDetail } from '@/api/notice/notice'; + export default function NoticeDetail() { const navigate = useNavigate(); const location = useLocation(); - const { title, date, content } = location.state || {}; + const noticeId = location.state?.noticeId; + + const [notice, setNotice] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (!noticeId) { + setError('공지사항 ID가 없습니다.'); + return; + } + + const loadNotice = async () => { + setLoading(true); + try { + const res = await fetchNoticeDetail(noticeId); + + if (!res.isSuccess || !res.result) { + throw new Error(res.message); + } + + setNotice(res.result); + } catch (err) { + console.error('📛 공지사항 상세 조회 오류:', err); + setError('공지사항을 불러오는 데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + loadNotice(); + }, [noticeId]); + + if (loading) return
    로딩 중...
    ; + if (error) return
    {error}
    ; return (
    @@ -12,12 +51,12 @@ export default function NoticeDetail() { {/* 공지 제목 + 날짜 */}
    -

    {title}

    -

    {date}

    +

    {notice?.title}

    +

    {new Date(notice?.createdAt || '').toLocaleDateString()}

    {/* 본문 */} -
    {content || '내용이 없습니다.'}
    +
    {notice?.content || '내용이 없습니다.'}
    {/* 목록으로 돌아가기 */}
    diff --git a/src/queryKey/queryKey.ts b/src/queryKey/queryKey.ts new file mode 100644 index 0000000..1e3fb30 --- /dev/null +++ b/src/queryKey/queryKey.ts @@ -0,0 +1,5 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const regionKeys = createQueryKeys('region', { + search: (keyword: string) => [keyword], +}); diff --git a/src/types/auth.ts b/src/types/auth/auth.ts similarity index 96% rename from src/types/auth.ts rename to src/types/auth/auth.ts index 301e92e..64272bb 100644 --- a/src/types/auth.ts +++ b/src/types/auth/auth.ts @@ -1,4 +1,4 @@ -import type { TCommonResponse } from './common/common'; +import type { TCommonResponse } from '@/types/common/common'; export enum Gender { MALE = 'MALE', diff --git a/src/types/dateCourse.ts b/src/types/dateCourse/dateCourse.ts similarity index 78% rename from src/types/dateCourse.ts rename to src/types/dateCourse/dateCourse.ts index e6734f4..c53a426 100644 --- a/src/types/dateCourse.ts +++ b/src/types/dateCourse/dateCourse.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; -import type { TCommonResponse } from './common/common'; +import type { TCommonResponse } from '@/types/common/common'; export type TTimeline = { end?: boolean; @@ -70,7 +70,24 @@ export type TSearchRegionValues = { }; export type TSearchRegionResponse = TCommonResponse<{ - regions: string[]; + regions: TRegion[]; keyword: string; resultCount: number; }>; + +export type TRegion = { + regionId: number; + name: string; + latitude: number; + longitude: number; + gridX: number; + gridY: number; + regionCode: { + landRegCode: string; + tempRegCode: string; + regionCodeId: number; + name: string; + }; + createdAt: string; + updatedAt: string; +}; diff --git a/src/types/notice/notice.ts b/src/types/notice/notice.ts new file mode 100644 index 0000000..a9d41e9 --- /dev/null +++ b/src/types/notice/notice.ts @@ -0,0 +1,28 @@ +import type { TCommonResponse } from '../common/common'; + +// 공지사항 전체 조회 +export type TNoticeItem = { + noticeId: number; + title: string; + isPinned: boolean; + createdAt: string; +}; + +export type TFetchNoticesResponse = TCommonResponse<{ + noticeList: TNoticeItem[]; + totalPages: number; + currentPage: number; + currentSize: number; + hasNextPage: boolean; +}>; + +// 공지사항 상세 조회 +export type TNoticeDetail = { + noticeId: number; + title: string; + content: string; + isPinned: boolean; + createdAt: string; +}; + +export type TFetchNoticeDetailResponse = TCommonResponse; diff --git a/src/utils/validation.tsx b/src/utils/validation.tsx index 1be358a..ad0b308 100644 --- a/src/utils/validation.tsx +++ b/src/utils/validation.tsx @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { Gender } from '@/types/auth'; +import { Gender } from '@/types/auth/auth'; const nicknamePattern = /^[a-zA-Z]+$/; const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; diff --git a/yarn.lock b/yarn.lock index 6bdf1e2..f20e38c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -600,7 +600,7 @@ "@lukemorales/query-key-factory@^1.3.4": version "1.3.4" - resolved "https://registry.npmjs.org/@lukemorales/query-key-factory/-/query-key-factory-1.3.4.tgz" + resolved "https://registry.yarnpkg.com/@lukemorales/query-key-factory/-/query-key-factory-1.3.4.tgz#d14001dbd781b024df93ca73bd785db590924486" integrity sha512-A3frRDdkmaNNQi6mxIshsDk4chRXWoXa05US8fBo4kci/H+lVmujS6QrwQLLGIkNIRFGjMqp2uKjC4XsLdydRw== "@napi-rs/wasm-runtime@^0.2.11": @@ -1103,12 +1103,7 @@ dependencies: "@types/lodash" "*" -"@types/lodash@*": - version "4.17.20" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz" - integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA== - -"@types/lodash@^4.17.20": +"@types/lodash@*", "@types/lodash@^4.17.20": version "4.17.20" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.20.tgz#1ca77361d7363432d29f5e55950d9ec1e1c6ea93" integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA== @@ -2384,9 +2379,9 @@ for-each@^0.3.3, for-each@^0.3.5: is-callable "^1.2.7" form-data@^4.0.0: - version "4.0.3" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz" - integrity sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA== + version "4.0.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8"