diff --git a/backend/routes/notices.js b/backend/routes/notices.js
new file mode 100644
index 0000000..6e926fe
--- /dev/null
+++ b/backend/routes/notices.js
@@ -0,0 +1,31 @@
+import { Router } from 'express';
+import axios from 'axios';
+
+const router = Router();
+
+const NEXON_API_BASE = 'https://open.api.nexon.com';
+
+const ENDPOINT_MAP = {
+ event: '/maplestory/v1/notice-event',
+ update: '/maplestory/v1/notice-update',
+ notice: '/maplestory/v1/notice',
+ cashshop: '/maplestory/v1/notice-cashshop',
+};
+
+router.get('/', async (req, res) => {
+ const type = req.query.type || 'event';
+ const endpoint = ENDPOINT_MAP[type];
+ if (!endpoint) return res.status(400).json({ error: '잘못된 type입니다' });
+
+ try {
+ const { data } = await axios.get(`${NEXON_API_BASE}${endpoint}`, {
+ headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
+ });
+ res.json(data);
+ } catch (err) {
+ console.error(`공지 조회 오류 (${type}):`, err.response?.data || err.message);
+ res.status(500).json({ error: '공지 조회 실패' });
+ }
+});
+
+export default router;
diff --git a/backend/server.js b/backend/server.js
index f52ac72..123ee18 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -2,6 +2,7 @@ import express from 'express';
import cors from 'cors';
import adminRoutes from './routes/admin.js';
import menuRoutes from './routes/menus.js';
+import noticeRoutes from './routes/notices.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
@@ -17,6 +18,7 @@ app.use(cors({
app.use(express.json());
app.use('/api/menus', menuRoutes);
+app.use('/api/notices', noticeRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx
new file mode 100644
index 0000000..dffc333
--- /dev/null
+++ b/frontend/src/components/Footer.jsx
@@ -0,0 +1,25 @@
+export default function Footer() {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index 363077a..d2df32d 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -1,8 +1,9 @@
import { Outlet, Link } from 'react-router-dom'
+import Footer from './Footer'
export default function Layout() {
return (
-
+
)
}
diff --git a/frontend/src/components/NoticeWidget.jsx b/frontend/src/components/NoticeWidget.jsx
new file mode 100644
index 0000000..8043be0
--- /dev/null
+++ b/frontend/src/components/NoticeWidget.jsx
@@ -0,0 +1,201 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { api } from '../api/client'
+
+const TABS = [
+ { key: 'event', label: '이벤트', dataKey: 'event_notice', filterOngoing: true, dateStartKey: 'date_event_start', dateEndKey: 'date_event_end' },
+ { key: 'cashshop', label: '캐시샵', dataKey: 'cashshop_notice', filterOngoing: true, dateStartKey: 'date_sale_start', dateEndKey: 'date_sale_end' },
+ { key: 'update', label: '업데이트', dataKey: 'update_notice' },
+ { key: 'notice', label: '공지', dataKey: 'notice' },
+]
+
+const DEFAULT_LIMIT = 6
+
+function formatDate(iso) {
+ if (!iso) return ''
+ const d = new Date(iso)
+ const m = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ return `${m}.${day}`
+}
+
+function isOngoing(notice, tab) {
+ if (!tab.filterOngoing) return false
+ const endDate = notice[tab.dateEndKey]
+ // 종료일이 있으면 종료일 비교
+ if (endDate) return new Date(endDate) > new Date()
+ // 종료일이 없으면 ongoing_flag로 판단 (캐시샵 상시판매 등)
+ if (notice.ongoing_flag !== undefined) {
+ return notice.ongoing_flag === 'true' || notice.ongoing_flag === true
+ }
+ return false
+}
+
+function splitTitle(title) {
+ // "3월 23일 캐시아이템 업데이트 - 메이플스토리 & 진" → ["3월 23일 캐시아이템 업데이트", "메이플스토리 & 진"]
+ const idx = title.indexOf(' - ')
+ if (idx === -1) return { prefix: null, main: title }
+ return {
+ prefix: title.slice(0, idx),
+ main: title.slice(idx + 3),
+ }
+}
+
+function NoticeCard({ notice, tab }) {
+ const startDate = tab.dateStartKey ? notice[tab.dateStartKey] : null
+ const endDate = tab.dateEndKey ? notice[tab.dateEndKey] : null
+ const hasDateRange = startDate || endDate
+
+ const dateText = hasDateRange
+ ? `${formatDate(startDate || notice.date)}${endDate ? ` ~ ${formatDate(endDate)}` : ''}`
+ : (tab.key === 'cashshop' && isOngoing(notice, tab) ? '상시판매' : formatDate(notice.date))
+
+ const { prefix, main } = splitTitle(notice.title)
+
+ return (
+
+ {notice.thumbnail_url && (
+
+

+
+ )}
+
+
+ {prefix && (
+
{prefix}
+ )}
+
+ {main}
+
+
+
+ 📅
+ {dateText}
+
+
+
+ )
+}
+
+export default function NoticeWidget() {
+ const [activeTab, setActiveTab] = useState('event')
+ const [expanded, setExpanded] = useState(false)
+ const tab = TABS.find((t) => t.key === activeTab)
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['notices', activeTab],
+ queryFn: () => api(`/api/notices?type=${activeTab}`),
+ staleTime: 5 * 60 * 1000,
+ })
+
+ const list = data?.[tab.dataKey] || []
+ const allItems = tab.filterOngoing
+ ? list.filter((n) => isOngoing(n, tab))
+ : list
+ const initialItems = allItems.slice(0, DEFAULT_LIMIT)
+ const extraItems = allItems.slice(DEFAULT_LIMIT)
+ const hasMore = extraItems.length > 0
+
+ const handleTabChange = (key) => {
+ setActiveTab(key)
+ setExpanded(false)
+ }
+
+ return (
+
+ {/* 헤더 + 탭 */}
+
+
+ 📢
+
메이플 공지
+ {tab.filterOngoing && allItems.length > 0 && (
+ 진행중 {allItems.length}건
+ )}
+
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+ {/* 목록 */}
+
+ {isLoading ? (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ ) : initialItems.length === 0 ? (
+
+ {tab.filterOngoing ? `진행중인 ${tab.label}이 없습니다` : `등록된 ${tab.label}이 없습니다`}
+
+ ) : (
+ <>
+
+ {initialItems.map((notice) => (
+
+ ))}
+
+
+ {/* 펼쳐지는 영역 - grid-template-rows 트릭으로 부드럽게 애니메이션 */}
+ {hasMore && (
+
+
+
+ {extraItems.map((notice) => (
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+ {/* 더보기 / 접기 */}
+ {hasMore && (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
index 821ae0c..60278db 100644
--- a/frontend/src/pages/Home.jsx
+++ b/frontend/src/pages/Home.jsx
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
+import NoticeWidget from '../components/NoticeWidget'
export default function Home() {
const { data: menus = [], isLoading: loading } = useQuery({
@@ -9,20 +10,16 @@ export default function Home() {
})
return (
-
- {/* Hero */}
-
-
-
- MapleStory Utility
-
-
- 메이플스토리 유틸리티
-
-
- 메이플스토리 플레이를 위한 유용한 도구 모음
-
-
+
+ {/* 메이플 공지 */}
+
+
+ {/* 구분선 */}
+
{/* 메뉴 그리드 */}