Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/api/notice/notice.ts
Original file line number Diff line number Diff line change
@@ -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<TFetchNoticesResponse> => {
const { data } = await axiosInstance.get('/api/v1/notices', {
params: { noticeCategory: category, page, size },
});
return data;
};

// 공지사항 상세 조회 API
export const fetchNoticeDetail = async (noticeId: number): Promise<TFetchNoticeDetailResponse> => {
const { data } = await axiosInstance.get(`/api/v1/notices/${noticeId}`);
return data;
};
5 changes: 5 additions & 0 deletions src/assets/icons/Burger_fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/icons/Clear.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 34 additions & 14 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className={`w-full ${showBorder ? 'border-b border-gray-200' : ''}`}>
{/* 최상단 네브바 */}
<div className="max-w-7xl mx-auto flex items-center justify-between px-4 lg:px-8 py-4">
{/* 로고 */}
<div className="flex items-center space-x-2">
<Link to="/home">
<NavbarLogo className="w-40 h-auto" />
</Link>
</div>
<Link to="/home">
<NavbarLogo className="w-40 h-auto" />
</Link>

{/* Nav 그룹 */}
{/* 데스크탑 메뉴 */}
{showNav && (
<div className="flex items-center gap-x-10 text-default-gray-00">
{/* 메뉴 */}
<div className="hidden lg:flex items-center gap-x-10 text-default-gray-00">
{/* 네비게이션 링크들 */}
<nav>
<ul className="flex space-x-5 sm:space-x-10 text-sm font-medium">
<ul className="flex space-x-5 sm:space-x-10">
<li>
<Link to="/home" className="font-body1">
메인
Expand All @@ -52,7 +54,7 @@ export default function Header({ mode = 'full' }: IHeaderProps) {
</ul>
</nav>

{/* 아이콘 */}
{/* 아이콘 버튼 */}
<div className="hidden lg:flex items-center space-x-5">
<Link to="/">
<NotificationsIcon className="w-5 h-5" fill="none" stroke="#000000" />
Expand All @@ -61,12 +63,30 @@ export default function Header({ mode = 'full' }: IHeaderProps) {
<SettingsIcon className="w-5 h-5" fill="none" stroke="#000000" />
</button>
</div>
</div>
)}

{/* 설정 모달 */}
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
{/* 모바일 메뉴 토글 버튼 */}
{showNav && (
<div className="lg:hidden">
{!isMobileMenuOpen ? (
<button onClick={() => setIsMobileMenuOpen(true)}>
<BurgerIcon className="w-6 h-6 text-default-gray-800" />
</button>
) : (
<button onClick={() => setIsMobileMenuOpen(false)}>
<ClearIcon className="w-6 h-6 text-default-gray-800" />
</button>
)}
</div>
)}
</div>

{/* 모바일 메뉴 */}
{isMobileMenuOpen && <MobileMenu onClose={() => setIsMobileMenuOpen(false)} onOpenSettings={() => setIsSettingsOpen(true)} />}

{/* 설정 모달 */}
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
</header>
);
}
66 changes: 66 additions & 0 deletions src/components/layout/MobileMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
{/* 검정 반투명 배경 오버레이 */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />

{/* 사이드 메뉴 */}
<div className="fixed top-0 right-0 h-full w-[80%] max-w-xs bg-white shadow-lg z-50 transform transition-transform duration-300 ease-in-out translate-x-0">
{/* 닫기 버튼 */}
<div className="flex justify-end p-4">
<button onClick={onClose}>
<ClearIcon className="w-6 h-6 text-default-gray-800" />
</button>
</div>

{/* 메뉴 목록 */}
<nav className="px-6">
<ul className="flex flex-col mt-5 gap-10 font-body1">
<li>
<Link to="/home" onClick={onClose}>
메인
</Link>
</li>
<li>
<Link to="/dateTest" onClick={onClose}>
데이트 취향 테스트
</Link>
</li>
<li>
<Link to="/dateCourse" onClick={onClose}>
데이트 코스
</Link>
</li>
</ul>

{/* 알림, 설정 */}
<div className="flex gap-5 mt-10">
<Link to="/" onClick={onClose}>
<NotificationsIcon className="w-5 h-5" fill="none" stroke="#000000" />
</Link>
<button
type="button"
onClick={() => {
onOpenSettings();
onClose();
}}
>
<SettingsIcon className="w-5 h-5" fill="none" stroke="#000000" />
</button>
</div>
</nav>
</div>
</>
);
}
124 changes: 56 additions & 68 deletions src/pages/notice/Notice.tsx
Original file line number Diff line number Diff line change
@@ -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<TNoticeItem[]>([]); // 공지사항 리스트
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 (
<div className="max-w-[1000px] mx-auto px-4 py-10">
Expand Down Expand Up @@ -102,15 +89,16 @@ export default function Notice() {
))}
</div>

{/* 공지 없을 때 메시지 */}
{!loading && !error && filteredNotices.length === 0 && <p className="text-center text-default-gray-500">공지사항이 없습니다.</p>}

{/* 공지 리스트 */}
<ul className="divide-y divide-default-gray-400 mb-10">
{paginatedNotices.map((notice, index) => (
<li key={index} className="py-4">
<Link to={`/notice/${index}`} state={notice} className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="font-body2 text-default-gray-800">{notice.title}</span>
</div>
<span className="text-sm text-default-gray-500">{notice.date}</span>
{filteredNotices.map((notice) => (
<li key={notice.noticeId} className="py-4">
<Link to={`/notice/${notice.noticeId}`} className="flex items-center justify-between">
<span className="font-body2 text-default-gray-800">{notice.title}</span>
<span className="text-sm text-default-gray-500">{new Date(notice.createdAt).toLocaleDateString()}</span>
</Link>
</li>
))}
Expand All @@ -122,7 +110,7 @@ export default function Notice() {
current={currentPage}
end={totalPages}
onClick={(page) => {
setCurrentPage(page);
setCurrentPage(page); //페이지 변경
}}
/>
)}
Expand Down
Loading