diff --git a/package-lock.json b/package-lock.json index 3bc7f40..c5e0d69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@heroui/system": "^2.4.7", "@heroui/theme": "^2.4.6", "@nextui-org/react": "^2.6.11", + "@react-google-maps/api": "^2.20.7", "@splidejs/react-splide": "^0.7.12", "@splidejs/splide": "^4.1.4", "@splidejs/splide-extension-auto-scroll": "^0.5.3", @@ -211,6 +212,22 @@ "tslib": "^2.8.0" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "license": "Apache-2.0" + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz", + "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -5525,6 +5542,36 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-google-maps/api": { + "version": "2.20.7", + "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz", + "integrity": "sha512-ys7uri3V6gjhYZUI43srHzSKDC6/jiKTwHNlwXFTvjeaJE3M3OaYBt9FZKvJs8qnOhL6i6nD1BKJoi1KrnkCkg==", + "license": "MIT", + "dependencies": { + "@googlemaps/js-api-loader": "1.16.8", + "@googlemaps/markerclusterer": "2.5.3", + "@react-google-maps/infobox": "2.20.0", + "@react-google-maps/marker-clusterer": "2.20.0", + "@types/google.maps": "3.58.1", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" + } + }, + "node_modules/@react-google-maps/infobox": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz", + "integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==", + "license": "MIT" + }, + "node_modules/@react-google-maps/marker-clusterer": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz", + "integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==", + "license": "MIT" + }, "node_modules/@react-stately/calendar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.6.0.tgz", @@ -6530,6 +6577,12 @@ "@types/estree": "*" } }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -8873,7 +8926,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -9582,6 +9634,15 @@ "tslib": "^2.8.0" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -10183,6 +10244,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13079,6 +13146,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 5209938..27839b4 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@heroui/system": "^2.4.7", "@heroui/theme": "^2.4.6", "@nextui-org/react": "^2.6.11", + "@react-google-maps/api": "^2.20.7", "@splidejs/react-splide": "^0.7.12", "@splidejs/splide": "^4.1.4", "@splidejs/splide-extension-auto-scroll": "^0.5.3", diff --git a/public/images/homecoming/bg.png b/public/images/homecoming/bg.png new file mode 100644 index 0000000..68a6e79 Binary files /dev/null and b/public/images/homecoming/bg.png differ diff --git a/public/images/homecoming/main_img.png b/public/images/homecoming/main_img.png new file mode 100644 index 0000000..62c5998 Binary files /dev/null and b/public/images/homecoming/main_img.png differ diff --git a/src/app/admin/member-manager/page.jsx b/src/app/admin/member-manager/page.jsx index 33b79d1..2ec3771 100644 --- a/src/app/admin/member-manager/page.jsx +++ b/src/app/admin/member-manager/page.jsx @@ -23,11 +23,11 @@ import AdminTableBottomContent from '@/components/admin/AdminTableBottomContent' /** 백엔드 enum과 일치하는 값(전송용) */ const ROLE_OPTIONS = ['GUEST', 'MEMBER', 'CORE', 'LEAD', 'ORGANIZER', 'ADMIN']; -const TEAM_ENUM_VALUES = ['BD', 'HR', 'TECH', 'PR_DESIGN']; +const TEAM_ENUM_VALUES = ['BD', 'HR', 'TECH', 'PR_DESIGN', 'HQ']; /** 화면 표시용 라벨 */ const TEAM_LABEL = { - BD: 'BD', HR: 'HR', TECH: 'TECH', PR_DESIGN: 'PR/DESIGN', + BD: 'BD', HR: 'HR', TECH: 'TECH', PR_DESIGN: 'PR/DESIGN', HQ: 'HQ', }; const roleColor = (r) => ({ diff --git a/src/app/core-attendance/page.jsx b/src/app/core-attendance/page.jsx index b1eaed4..d6e07d3 100644 --- a/src/app/core-attendance/page.jsx +++ b/src/app/core-attendance/page.jsx @@ -14,6 +14,32 @@ const setQS = (entries) => { window.history.replaceState({}, '', u.toString()); }; +// ===== CSV 저장 공통 헬퍼 ===== +const saveResponseAsFile = (res, fallbackName) => { + // Excel 한글 깨짐 방지용 BOM + const BOM = new Uint8Array([0xEF, 0xBB, 0xBF]); + const blob = new Blob([BOM, res.data], { type: 'text/csv;charset=utf-8' }); + + // 서버가 파일명 내려주면 우선 사용 + let filename = fallbackName; + const cd = res.headers && (res.headers['content-disposition'] || res.headers['Content-Disposition']); + if (cd) { + const m = /filename\*=UTF-8''([^;]+)|filename="?([^"]+)"?/i.exec(cd); + const decoded = m && decodeURIComponent((m[1] || m[2] || '').trim()); + if (decoded) filename = decoded; + } + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +}; + + export default function AttendancePage() { const {apiClient} = useAuthenticatedApi(); @@ -49,6 +75,17 @@ export default function AttendancePage() { })).data.data, summary: async (d) => (await apiClient.get(`/core-attendance/meetings/${d}/summary`)).data.data, + + downloadSummaryCsvForDateAndSave: async (d) => { + const res = await apiClient.get(`/core-attendance/meetings/${d}/summary.csv`, { responseType: 'blob' }); + saveResponseAsFile(res, `attendance-${d}.csv`); + }, + + downloadSummaryCsvAllAndSave: async () => { + const res = await apiClient.get('/core-attendance/meetings/summary.csv', { responseType: 'blob' }); + saveResponseAsFile(res, 'attendance-summary.csv'); + }, + }; /** URL 동기화 */ @@ -366,6 +403,25 @@ export default function AttendancePage() { 요약 + + + + {summary ? (
전체 {summary.present} / {summary.total}
diff --git a/src/app/homecoming/layout.js b/src/app/homecoming/layout.js new file mode 100644 index 0000000..806b333 --- /dev/null +++ b/src/app/homecoming/layout.js @@ -0,0 +1,15 @@ +import { Suspense } from "react"; +import Loader from '@/components/ui/common/Loader.jsx'; + +export const metadata = { + title: "Homecoming", + description: "GDGoC INHA 제1회 홈커밍 데이 행사 안내 및 참여 페이지", +}; + +export default function HomecomingLayout({ children }) { + return ( + }> + {children} + + ); +} \ No newline at end of file diff --git a/src/app/homecoming/page.jsx b/src/app/homecoming/page.jsx new file mode 100644 index 0000000..6ddd710 --- /dev/null +++ b/src/app/homecoming/page.jsx @@ -0,0 +1,252 @@ +'use client'; + +import {useState} from 'react'; +import {GoogleMap, Marker, useJsApiLoader} from '@react-google-maps/api'; +import Image from 'next/image'; + +export default function HomecomingPage() { + const [showInvitation, setShowInvitation] = useState(false); + + return (
+ {/* 🔥 모바일/태블릿: 화면에 딱 고정되는 배경 (fixed) */} +
+ + {/* 💻 PC: 내용 높이만큼 아래로 늘어나는 배경 */} +
+ + {/* 1920px 기준 캔버스 */} +
+ + {/* 실제 콘텐츠 */} +
+ {showInvitation ? ( setShowInvitation(false)}/>) : ( + setShowInvitation(true)}/>)} +
+
+
); +} + +/* =============================== + Hero 컴포넌트 +================================ */ +function Hero({onOpenInvitation}) { + return (
+ GDGoC INHA Homecoming Day Illustration + + +

+ GDGoC INHA +
+ 제1회 홈커밍데이에 초대합니다! +

+ + +
); +} + +/* =============================== + Invitation 컴포넌트 +================================ */ +function Invitation({onBack}) { + return (
+ {/* 초대장 카드 */} +
+ + {/* 상단 라벨 + 뒤로가기 */} +
+ + Invitation + + {onBack && ()} +
+ + {/* 1. 소개 */} +
+

+ GDGoC INHA{" "} +
+ 제1회 홈커밍 데이 +

+

+ GDGoC INHA Homecoming Day 2025 +

+ +
+ {/* 1줄 소개 */} +

+ GDGoC INHA가 처음으로 선보이는{" "} + 제1회 홈커밍 데이(Homecoming Day)가 열립니다! +

+ + {/* 2번째 문장: 굵은 부분은 데스크탑에서 따로 줄바꿈 */} +

+ 이번 행사는 현역 부원과 OB, 그리고 GDG 커뮤니티에 관심 있는 모든 분들이 한자리에 모여 +
+ + 프로젝트 성과 공유 · 기술 교류 · 커뮤니티 네트워킹 + + 을 함께 나누는 뜻깊은 자리입니다. +

+ + {/* 3번째 문장 */} +

+ 한 해 동안의 활동을 돌아보고, 앞으로 GDGoC INHA가 만들어갈 방향을 함께 이야기하며 +
+ 커뮤니티의 가치를 더욱 확장하는 의미 있는 시간을 준비했습니다. +

+
+
+ + {/* 2~3. 일시/장소 + 프로그램 (PC에서 2열 배치) */} +
+ {/* 왼쪽 컬럼: 일시 + 장소 */} +
+ {/* 일시 */} +
+

+ 일시 +

+

+ 2025년 12월 20일 (토) +

+
+ + {/* 장소 */} +
+

+ 장소 +

+

+ 신한 스퀘어 브릿지 인천 +
+ (인천 연수구 컨벤시아대로 204 인스타2) +

+
+
+ + {/* 오른쪽 컬럼: 프로그램 안내 */} +
+

+ 프로그램 안내 +

+ +
+
+

+ 1부(13:00~) GOAT Final Day +

+
    +
  • + 팀 프로젝트 데모 시연 및 피칭 발표 +
  • +
+
+ +
+

+ 2부(16:00~) Networking Session +

+
    +
  • 오프닝 강연
  • +
  • GDGoC INHA 연간 활동 소개 및 커뮤니티 정리
  • +
  • 기술 강연(Technical Talk) - 2세션
  • +
  • 자유로운 네트워킹
  • +
+
+ +
+

+ 뒷풀이 (19:00~) +

+
+
+
+
+ + {/* 4. 지도 (찾아오는 길) */} +
+

+ 찾아오는 길 +

+ +
+
+
); +} + +function HomecomingMap() { + const {isLoaded, loadError} = useJsApiLoader({ + googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, id: 'homecoming-map-script', + }); + + const center = {lat: 37.388493, lng: 126.639989}; + + if (loadError) { + return (
+ 지도를 불러오는 중 오류가 발생했습니다. +
); + } + + if (!isLoaded) { + return (
+ 지도를 불러오는 중입니다... +
); + } + + return (
+ + + +
); +} \ No newline at end of file diff --git a/src/app/manitto/admin/layout.js b/src/app/manitto/admin/layout.js new file mode 100644 index 0000000..7df267a --- /dev/null +++ b/src/app/manitto/admin/layout.js @@ -0,0 +1,15 @@ +import MenuHeader from "@/components/ui/common/MenuHeader"; +import ApiCodeGuard from "@/components/auth/ApiCodeGuard.jsx"; + +export const metadata = { + title: "Manitto Admin", description: "GDGoC INHA Manitto Management", +}; + +export default function ManittoAdminLayout({children}) { + return ( + <> + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/manitto/admin/page.jsx b/src/app/manitto/admin/page.jsx new file mode 100644 index 0000000..7f0e6dc --- /dev/null +++ b/src/app/manitto/admin/page.jsx @@ -0,0 +1,370 @@ +'use client'; + +import {useEffect, useState} from 'react'; +import {Button, Card, CardBody, CardHeader, Divider, Input} from '@nextui-org/react'; +import {useAuthenticatedApi} from '@/hooks/useAuthenticatedApi'; + +export default function ManitoAdminPage() { + const {apiClient} = useAuthenticatedApi(); + + // 공통 + const [sessionCode, setSessionCode] = useState(''); + + // 세션 관리용 + const [sessions, setSessions] = useState([]); // [{id, code, name, createdAt,...}] + const [newSessionCode, setNewSessionCode] = useState(''); + const [newSessionTitle, setNewSessionTitle] = useState(''); + const [loadingSessions, setLoadingSessions] = useState(false); + + // 파일 + const [participantsFile, setParticipantsFile] = useState(null); + const [encryptedFile, setEncryptedFile] = useState(null); + + // 로딩 + const [loadingParticipants, setLoadingParticipants] = useState(false); + const [loadingEncrypted, setLoadingEncrypted] = useState(false); + + // 메시지 + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + + const resetMessages = () => { + setMessage(''); + setError(''); + }; + + /** ====== 세션 목록 불러오기 ====== */ + const fetchSessions = async () => { + try { + setLoadingSessions(true); + const res = await apiClient.get('/admin/manito/sessions'); + // ApiResponse 가정: { data: [ ... ] } + const list = res.data?.data ?? []; + setSessions(Array.isArray(list) ? list : []); + } catch (e) { + console.error(e); + setError('세션 목록을 불러오는 중 오류가 발생했습니다.'); + } finally { + setLoadingSessions(false); + } + }; + + useEffect(() => { + fetchSessions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** ====== 세션 생성 ====== */ + const handleCreateSession = async () => { + resetMessages(); + const code = newSessionCode.trim(); + const title = newSessionTitle.trim(); + + if (!code) { + setError('세션 코드를 입력해 주세요.'); + return; + } + if (!title) { + setError('세션 이름을 입력해 주세요.'); + return; + } + + try { + setLoadingSessions(true); + await apiClient.post('/admin/manito/sessions', {code, title}); + + // 세션 목록 갱신 + await fetchSessions(); + // 공통 sessionCode 에도 세팅 + setSessionCode(code); + setNewSessionCode(''); + setNewSessionTitle(''); + setMessage(`세션이 생성되었습니다. (code: ${code})`); + } catch (e) { + console.error(e); + setError('세션 생성 중 오류가 발생했습니다.'); + } finally { + setLoadingSessions(false); + } + }; + + /** ===== 파일 핸들러 ===== */ + const handleParticipantsFileChange = (e) => { + resetMessages(); + const file = e.target.files?.[0] ?? null; + setParticipantsFile(file); + }; + + const handleEncryptedFileChange = (e) => { + resetMessages(); + const file = e.target.files?.[0] ?? null; + setEncryptedFile(file); + }; + + /** ===== 다운로드 공통 util ===== */ + const downloadBlob = (blob, filename) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + }; + + /** ===== 1단계: 참가자 CSV 업로드 → 매칭 CSV 다운로드 ===== */ + const handleUploadParticipants = async () => { + resetMessages(); + + if (!sessionCode.trim()) { + setError('세션 코드를 선택하거나 입력해 주세요.'); + return; + } + if (!participantsFile) { + setError('참가자 CSV 파일을 선택해 주세요.'); + return; + } + + try { + setLoadingParticipants(true); + + const formData = new FormData(); + formData.append('sessionCode', sessionCode.trim()); + formData.append('file', participantsFile); + + const res = await apiClient.post('/admin/manito/upload', formData, { + responseType: 'blob', + }); + + const blob = res.data; + const disposition = res.headers?.['content-disposition'] || ''; + let filename = `manito-${sessionCode.trim()}.csv`; + const match = disposition.match(/filename="?([^"]+)"?/); + if (match && match[1]) { + filename = match[1]; + } + + downloadBlob(blob, filename); + setMessage('참가자 CSV 업로드 및 매칭 CSV 다운로드가 완료되었습니다.'); + } catch (e) { + console.error(e); + setError('참가자 CSV 업로드 또는 매칭 CSV 다운로드 중 오류가 발생했습니다.'); + } finally { + setLoadingParticipants(false); + } + }; + + /** ===== 2단계: 암호문 CSV 업로드 ===== */ + const handleUploadEncrypted = async () => { + resetMessages(); + + if (!sessionCode.trim()) { + setError('세션 코드를 선택하거나 입력해 주세요.'); + return; + } + if (!encryptedFile) { + setError('암호문 CSV 파일을 선택해 주세요.'); + return; + } + + try { + setLoadingEncrypted(true); + + const formData = new FormData(); + formData.append('sessionCode', sessionCode.trim()); + formData.append('file', encryptedFile); + + const res = await apiClient.post('/admin/manito/upload-encrypted', formData); + const body = res.data; + setMessage(body?.message || '암호문 CSV 업로드가 완료되었습니다.'); + } catch (e) { + console.error(e); + setError('암호문 CSV 업로드 중 오류가 발생했습니다.'); + } finally { + setLoadingEncrypted(false); + } + }; + + return (
+

마니또 관리(Admin)

+ + {/* 공통 설정 + 세션 등록/선택 */} + + +

공통 설정 · 세션 관리

+

+ 세션 단위로 참가자/매칭/암호문을 관리합니다. +
+ 먼저 세션을 생성한 뒤, 해당 세션을 선택하고 아래 단계를 진행하세요. +

+
+ + + {/* 현재 사용 세션 코드 (직접 입력/수정 가능) */} + setSessionCode(e.target.value)} + variant="bordered" + classNames={{ + label: 'text-zinc-300', + input: 'text-white', + inputWrapper: 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-400', + }} + /> + + + + {/* 새 세션 생성 */} +
+

새 세션 등록

+
+ setNewSessionCode(e.target.value)} + variant="bordered" + classNames={{ + label: 'text-zinc-300', + input: 'text-white', + inputWrapper: 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-400', + }} + /> + setNewSessionTitle(e.target.value)} + variant="bordered" + classNames={{ + label: 'text-zinc-300', + input: 'text-white', + inputWrapper: 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-400', + }} + /> +
+ +
+ + {/* 세션 목록 */} + +
+

세션 목록

+
+ {loadingSessions && (

세션 목록을 불러오는 중...

)} + {!loadingSessions && sessions.length === 0 && (

+ 등록된 세션이 없습니다. 위에서 새 세션을 생성해 주세요. +

)} + {sessions.map((s) => (
+
+ + {s.title || '(이름 없음)'} + + + code: {s.code} + +
+ +
))} +
+
+
+
+ + {/* 1단계: 참가자 CSV 업로드 */} + + +

+ 1단계 · 참가자 CSV 업로드 & 매칭 생성 +

+

+ CSV 헤더: studentId,name,pin · 업로드 후, 서버에서 매칭을 생성하고 +
+ giverStudentId,giverName,receiverStudentId,receiverName CSV를 + 바로 다운로드합니다. +

+
+ + + + + +
+ + {/* 2단계: 암호문 CSV 업로드 */} + + +

+ 2단계 · 암호문(encryptedManitto) CSV 업로드 +

+

+ 클라이언트에서 매칭 CSV를 기반으로 암호화한 결과를 업로드합니다. +
+ CSV 헤더 예시: studentId,encryptedManitto +

+
+ + + + + +
+ + {(message || error) && ( + + {message && (

{message}

)} + {error &&

{error}

} +
+
)} +
); +} \ No newline at end of file diff --git a/src/app/manitto/layout.js b/src/app/manitto/layout.js new file mode 100644 index 0000000..938cf37 --- /dev/null +++ b/src/app/manitto/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: '마니또 확인', + description: '마니또 매칭 결과를 확인합니다.', +}; + +export default function ManitoLayout({ children }) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/app/manitto/page.jsx b/src/app/manitto/page.jsx new file mode 100644 index 0000000..58aa9a8 --- /dev/null +++ b/src/app/manitto/page.jsx @@ -0,0 +1,247 @@ +'use client'; + +import {useEffect, useState} from 'react'; +import {useSearchParams} from 'next/navigation'; +import {Button, Card, CardBody, CardHeader, Divider, Input} from '@nextui-org/react'; +import {useAuthenticatedApi} from '@/hooks/useAuthenticatedApi'; + +export default function ManitoVerifyPage() { + const searchParams = useSearchParams(); + const {apiClient} = useAuthenticatedApi(); + + const [sessionCode, setSessionCode] = useState(''); + const [studentId, setStudentId] = useState(''); + const [hash, setHash] = useState(''); + + const [pin, setPin] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [cipher, setCipher] = useState(''); + const [plain, setPlain] = useState(null); // { receiverStudentId, receiverName } + const [ownerName, setOwnerName] = useState(''); // 요청자 이름 (API에서 내려주는 값 가정) + + useEffect(() => { + if (!searchParams) return; + const sCode = searchParams.get('sessionCode') || ''; + const sId = searchParams.get('studentId') || ''; + const h = searchParams.get('hash') || ''; + + setSessionCode(sCode); + setStudentId(sId); + setHash(h); + }, [searchParams]); + + /** ========= Base64URL → Uint8Array ========= */ + const base64UrlToBytes = (str) => { + if (!str) return new Uint8Array(); + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4 !== 0) base64 += '='; + const binary = typeof atob !== 'undefined' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary'); + + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + }; + + /** ========= 실제 복호화 로직 (AES-256-GCM) ========= + * encrypted: base64url(nonce(12) + ciphertext+tag) + * hashKey: base64url(32바이트 랜덤) → 그대로 AES 키 + */ + const tryDecrypt = async (encrypted, hashKey) => { + if (!encrypted || !hashKey) return null; + if (typeof window === 'undefined' || !window.crypto?.subtle) return null; + + try { + const combined = base64UrlToBytes(encrypted); + if (combined.length <= 12) return null; + + const nonce = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const keyBytes = base64UrlToBytes(hashKey); + if (keyBytes.length !== 32) { + console.warn('Unexpected key length:', keyBytes.length); + return null; + } + + const key = await window.crypto.subtle.importKey('raw', keyBytes, {name: 'AES-GCM'}, false, ['decrypt'],); + + const plainBuffer = await window.crypto.subtle.decrypt({name: 'AES-GCM', iv: nonce}, key, ciphertext,); + + const dec = new TextDecoder(); + const jsonStr = dec.decode(plainBuffer); + return JSON.parse(jsonStr); + } catch (e) { + console.error('Manito decrypt error:', e); + return null; + } + }; + + /** 🔥 encrypted(cipher) 값이 세팅되면 hash로 자동 복호화 */ + useEffect(() => { + if (!cipher || !hash) { + setPlain(null); + return; + } + + let cancelled = false; + + (async () => { + const decoded = await tryDecrypt(cipher, hash); + if (cancelled) return; + + if (decoded) { + setPlain(decoded); + } else { + // 여기서도 error 세팅 + setError((prev) => (prev ? prev + '\n' : '') + '복호화에 실패했습니다. 해시 값이 올바른지 확인해 주세요.',); + setPlain(null); + } + })(); + + return () => { + cancelled = true; + }; + }, [cipher, hash]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setCipher(''); + setPlain(null); + setOwnerName(''); + + if (!sessionCode || !studentId) { + setError('세션 코드 또는 학번 정보가 잘못되었습니다. 링크를 다시 확인해 주세요.'); + return; + } + + if (!/^\d{4}$/.test(pin)) { + setError('PIN은 숫자 4자리여야 합니다.'); + return; + } + + try { + setLoading(true); + + const res = await apiClient.post('/manito/verify', {sessionCode, studentId, pin}, {},); + + const body = res.data; + + // ✅ 서버 응답 키 이름 맞춰서 사용 + const encrypted = body?.data?.encryptedManito || ''; + const owner = body?.data?.ownerName || ''; // 백엔드에서 내려준다고 가정 + + setOwnerName(owner); + setCipher(encrypted); // 🔥 cipher가 바뀌면 useEffect가 hash로 복호화 + } catch (err) { + const res = err?.response; + const msg = res?.data?.message || res?.data?.error || err?.message || '알 수 없는 오류가 발생했습니다.'; + setError(msg); + } finally { + setLoading(false); + } + }; + + const disabled = !sessionCode || !studentId; + + // 결과 문구 구성 + const receiverName = plain?.receiverName; + const ownerLabel = ownerName || '당신'; + + return (
+ + + 🎁 GDGoC INHA · 마니또 +

+ 마니또 확인하기 +

+

+ 전달받은 링크로 접속한 뒤,
+ 본인이 설정한 PIN 4자리를 입력해 주세요. +

+
+ + + + + {disabled && (

+ 필수 정보가 누락되었습니다. 링크를 다시 확인해 주세요. +

)} + +
+ setPin(e.target.value.replace(/[^0-9]/g, '').slice(0, 4),)} + classNames={{ + label: 'text-zinc-300', + input: 'text-white text-base tracking-[0.25em]', + inputWrapper: 'bg-zinc-900 border-zinc-700 group-data-[focus=true]:border-zinc-300', + }} + isDisabled={disabled || loading} + /> + + {error && (

+ {error} +

)} + + +
+ + {/* 결과 영역 */} + {(cipher || plain) && (<> + +
+

+ 결과 +

+ + {plain ? (
+

+ {ownerLabel} + 님의 마니또는 + + {receiverName || '알 수 없음'} + + 님입니다! 🎉 +

+

+ 이 정보는 브라우저에서 해시값을 사용해 복호화한 결과이며, + 서버에는 평문으로 저장되지 않습니다. +

+
) : (
+

+ 복호화에 실패하였습니다. +

+

+ 해시 값이 올바른지, 전달받은 링크가 정확한지 다시 한 번 + 확인해 주세요. 문제가 계속되면 운영진에게 문의해 주세요. +

+
)} +
+ )} +
+
+
); +} \ No newline at end of file diff --git a/src/hooks/useAuthenticatedApi.js b/src/hooks/useAuthenticatedApi.js index 0551c51..e9eeea0 100644 --- a/src/hooks/useAuthenticatedApi.js +++ b/src/hooks/useAuthenticatedApi.js @@ -46,15 +46,18 @@ export const useAuthenticatedApi = () => { // 요청 인터셉터 client.interceptors.request.use((config) => { - if (!config.headers['Content-Type']) { + // FormData인지 체크 + const isFormData = typeof FormData !== 'undefined' && config.data instanceof FormData; + if (!isFormData && !config.headers['Content-Type']) { config.headers['Content-Type'] = 'application/json'; } if (accessTokenRef.current) { config.headers.Authorization = `Bearer ${accessTokenRef.current}`; } + return config; - }, (error) => Promise.reject(error)); + }, (error) => Promise.reject(error),); // 응답 인터셉터 client.interceptors.response.use((response) => response, async (error) => { diff --git a/yarn.lock b/yarn.lock index 3936940..9fc010b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -107,6 +107,19 @@ dependencies: tslib "^2.8.0" +"@googlemaps/js-api-loader@1.16.8": + version "1.16.8" + resolved "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz" + integrity sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ== + +"@googlemaps/markerclusterer@2.5.3": + version "2.5.3" + resolved "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz" + integrity sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw== + dependencies: + fast-deep-equal "^3.1.3" + supercluster "^8.0.1" + "@heroicons/react@^2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz" @@ -2026,6 +2039,28 @@ "@react-types/shared" "^3.29.0" "@swc/helpers" "^0.5.0" +"@react-google-maps/api@^2.20.7": + version "2.20.7" + resolved "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz" + integrity sha512-ys7uri3V6gjhYZUI43srHzSKDC6/jiKTwHNlwXFTvjeaJE3M3OaYBt9FZKvJs8qnOhL6i6nD1BKJoi1KrnkCkg== + dependencies: + "@googlemaps/js-api-loader" "1.16.8" + "@googlemaps/markerclusterer" "2.5.3" + "@react-google-maps/infobox" "2.20.0" + "@react-google-maps/marker-clusterer" "2.20.0" + "@types/google.maps" "3.58.1" + invariant "2.2.4" + +"@react-google-maps/infobox@2.20.0": + version "2.20.0" + resolved "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz" + integrity sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ== + +"@react-google-maps/marker-clusterer@2.20.0": + version "2.20.0" + resolved "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz" + integrity sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw== + "@react-stately/calendar@^3.6.0", "@react-stately/calendar@3.6.0": version "3.6.0" resolved "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.6.0.tgz" @@ -2703,6 +2738,11 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/google.maps@3.58.1": + version "3.58.1" + resolved "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz" + integrity sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ== + "@types/hast@^3.0.0": version "3.0.4" resolved "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz" @@ -4404,6 +4444,13 @@ intl-messageformat@^10.1.0: "@formatjs/icu-messageformat-parser" "2.11.2" tslib "^2.8.0" +invariant@2.2.4: + version "2.2.4" + resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + is-alphabetical@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz" @@ -4718,6 +4765,11 @@ json5@^1.0.2: object.assign "^4.1.4" object.values "^1.1.6" +kdbush@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz" + integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== + keyv@^4.5.3: version "4.5.4" resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" @@ -4772,7 +4824,7 @@ longest-streak@^3.0.0: resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz" integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -5670,7 +5722,7 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -"react-dom@^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom@^18.0.0 || ^19.0.0", react-dom@^18.2.0, "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@>=18, "react-dom@>=18 || >=19.0.0-rc.0": +"react-dom@^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8 || ^17 || ^18 || ^19", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom@^18.0.0 || ^19.0.0", react-dom@^18.2.0, "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@>=18, "react-dom@>=18 || >=19.0.0-rc.0": version "18.3.1" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== @@ -5729,7 +5781,7 @@ react-textarea-autosize@^8.5.3: use-composed-ref "^1.3.0" use-latest "^1.2.1" -react@*, "react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^18.0 || ^19", "react@^18.0.0 || ^19.0.0", react@^18.2.0, "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^18.3.1, "react@>= 16 || ^19.0.0-rc", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", react@>=16.13.1, react@>=18, "react@>=18 || >=19.0.0-rc.0": +react@*, "react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17 || ^18 || ^19", "react@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^18.0 || ^19", "react@^18.0.0 || ^19.0.0", react@^18.2.0, "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^18.3.1, "react@>= 16 || ^19.0.0-rc", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", react@>=16.13.1, react@>=18, "react@>=18 || >=19.0.0-rc.0": version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -6271,6 +6323,13 @@ sucrase@^3.35.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" +supercluster@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz" + integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ== + dependencies: + kdbush "^4.0.2" + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"