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 ( -
+
@@ -13,9 +14,10 @@ export default function Layout() {
-
+
+
) } 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 -
-

- 메이플스토리 유틸리티 -

-

- 메이플스토리 플레이를 위한 유용한 도구 모음 -

-
+
+ {/* 메이플 공지 */} + + + {/* 구분선 */} +
+
+ Utilities +
+
{/* 메뉴 그리드 */}