diff --git a/src/App.jsx b/src/App.jsx index f0792b9..22e3902 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -29,7 +29,7 @@ import FaqScreen from './components/screens/FaqScreen'; import CertificationHistoryScreen from './components/screens/CertificationHistoryScreen'; import CarbonInfoScreen from './components/screens/CarbonInfoScreen'; import AddChallengeScreen from './components/screens/AddChallengeScreen'; - +import AdminScreen from './components/screens/AdminScreen'; // Onboarding, Home, Map, Certification components live in src/components/screens const TAB_TO_PATH = { @@ -41,6 +41,7 @@ const TAB_TO_PATH = { mypage: '/mypage', points: '/points', 'point-exchange': '/point-exchange', + admin: '/admin', ranking: '/ranking', login: '/login', badge: '/badge', @@ -149,17 +150,22 @@ export default function App() { path='/addChallenge' element={} /> + } + /> {/* 404: 알 수 없는 경로는 홈으로 리디렉션 */} } /> - {/* 하단 네비게이션 바 - addChallenge 페이지에서는 숨김 */} - {location.pathname !== '/addChallenge' && ( - navigate(tab)} - /> - )} + {/* 하단 네비게이션 바 - addChallenge, admin 페이지에서는 숨김 */} + {location.pathname !== '/addChallenge' && + location.pathname !== '/admin' && ( + navigate(tab)} + /> + )} ); } diff --git a/src/api/badgeApi.js b/src/api/badgeApi.js new file mode 100644 index 0000000..4b619e7 --- /dev/null +++ b/src/api/badgeApi.js @@ -0,0 +1,16 @@ +import api from './axios'; + +export async function getBadges() { + const res = await api.get('/badge'); + return res.data.data; +} + +export async function registerBadge(req) { + const res = await api.post('/badge', req); + return res.data.data; +} + +export async function selectBadge(badgeName) { + const res = await api.get('/badge/select', { params: { badgeName } }); + return res.data.data; +} diff --git a/src/components/badge/BadgeForm.jsx b/src/components/badge/BadgeForm.jsx new file mode 100644 index 0000000..d7d4ff6 --- /dev/null +++ b/src/components/badge/BadgeForm.jsx @@ -0,0 +1,247 @@ +import React, { useState } from 'react'; +import { registerBadge } from '../../api/badgeApi'; + +// 뱃지 카테고리 키워드 (챌린지와 동일) +const VALID_CATEGORIES = [ + '따릉이', + '전기차', + '수소차', + '재활용센터', + '제로웨이스트', +]; + +const BadgeForm = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [name, setName] = useState(''); + const [desc, setDesc] = useState(''); + const [icon, setIcon] = useState(''); + const [category, setCategory] = useState(''); + const [requirement, setRequirement] = useState(''); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + // 필수 필드 검사 + if (!name || !desc || !icon || !category || !requirement) { + setError('비어있는 칸이 있습니다. 칸을 모두 채워주세요.'); + return; + } + + // 숫자 필드 검증 + const requirementNum = parseInt(requirement, 10); + + if (isNaN(requirementNum) || requirementNum <= 0) { + setError('요구 포인트는 양수여야 합니다.'); + return; + } + + const badgeData = { + category: category.trim(), + name: name.trim(), + requirement: requirementNum, + description: desc.trim(), + image_url: icon.trim(), + }; + + setIsLoading(true); + + try { + const res = await registerBadge(badgeData); + console.log('뱃지 추가 응답:', res); + + alert('✅ 뱃지가 성공적으로 등록되었습니다!'); + // 폼 초기화 + setName(''); + setDesc(''); + setIcon(''); + setCategory(''); + setRequirement(''); + } catch (err) { + console.error('뱃지 추가 실패', err.response || err); + + if (err.response?.status === 401) { + setError('❌ 인증이 필요합니다. 다시 로그인해주세요.'); + } else if (err.response?.status === 400) { + setError('❌ 입력 형식이 올바르지 않습니다.'); + } else if (err.response?.data?.message) { + setError(`❌ ${err.response.data.message}`); + } else { + setError('❌ 뱃지 추가 중 오류가 발생했습니다.'); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + + 뱃지 작성 + + + {/* 전역 에러 메시지 */} + {error && ( + + {error} + + )} + + + + + 카테고리 * + + setCategory(e.target.value)} + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + > + 카테고리를 선택하세요 + {VALID_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + + 뱃지의 카테고리를 선택하세요. + + + + + + 뱃지 이름 + + setName(e.target.value)} + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='예: 초록이 뱃지' + /> + + + + + 설명 + + setDesc(e.target.value)} + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='뱃지에 대한 설명을 입력하세요' + /> + + + + + 요구 포인트 + + setRequirement(e.target.value)} + min='1' + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='1000' + /> + + 이 뱃지를 획득하기 위해 필요한 포인트를 입력하세요. + + + + + + 아이콘 URL + + setIcon(e.target.value)} + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='https://example.com/badge-icon.png' + /> + + 뱃지 아이콘 이미지의 URL을 입력하세요. + + + + + + {isLoading ? ( + + + + + + 등록 중... + + ) : ( + '추가하기' + )} + + + + + {/* 카테고리 안내 */} + + + 📋 카테고리 안내 + + + + • 따릉이: 자전거 이용 관련 뱃지 + + + • 전기차: 전기차 충전 관련 뱃지 + + + • 수소차: 수소차 충전 관련 뱃지 + + + • 재활용센터: 재활용센터 방문 관련 뱃지 + + + • 제로웨이스트: 제로웨이스트 상점 이용 + 관련 뱃지 + + + + + ); +}; + +export default BadgeForm; diff --git a/src/components/badge/BadgeList.jsx b/src/components/badge/BadgeList.jsx new file mode 100644 index 0000000..3b2a270 --- /dev/null +++ b/src/components/badge/BadgeList.jsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; +import { getBadges, selectBadge } from '../../api/badgeApi'; + +function BadgeListExample() { + const [badges, setBadges] = useState([]); + const [error, setError] = useState(''); + + useEffect(() => { + getBadges().then(setBadges).catch(setError); + }, []); + + const handleSelect = async (name) => { + try { + await selectBadge(name); + setBadges((prev) => + prev.map((b) => ({ + ...b, + isSelected: b.name === name, + })) + ); + } catch (err) { + setError(String(err)); + } + }; + + if (error) return {error}; + + return ( + + {badges.map((badge) => ( + + + {badge.name} + handleSelect(badge.name)} + > + {badge.isSelected ? '선택됨' : '선택'} + + + ))} + + ); +} + +export default BadgeListExample; diff --git a/src/components/challenge/ChallengeForm.jsx b/src/components/challenge/ChallengeForm.jsx new file mode 100644 index 0000000..c686fd8 --- /dev/null +++ b/src/components/challenge/ChallengeForm.jsx @@ -0,0 +1,345 @@ +import React, { useState } from 'react'; +import api from '../../api/axios'; + +// 챌린지 타입 키워드 (백엔드 자동 인증 연동용) +const VALID_CHALLENGE_TYPES = [ + '따릉이', + '전기차', + '수소차', + '재활용센터', + '제로웨이스트', +]; + +const ChallengeForm = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [descriptionError, setDescriptionError] = useState(''); + + // Description 유효성 검사 함수 + const validateDescription = (description) => { + if (!description.trim()) { + return '설명을 입력해주세요.'; + } + + const firstWord = description.trim().split(' ')[0]; + if (!VALID_CHALLENGE_TYPES.includes(firstWord)) { + return `설명은 다음 키워드 중 하나로 시작해야 합니다: ${VALID_CHALLENGE_TYPES.join( + ', ' + )}`; + } + + return ''; + }; + + // Description 입력 변경 핸들러 + const handleDescriptionChange = (e) => { + const value = e.target.value; + const validationError = validateDescription(value); + setDescriptionError(validationError); + }; + + const handleAddChallenge = async () => { + setError(''); + + const challengeName = document + .getElementById('challengeName') + .value.trim(); + const description = document.getElementById('description').value.trim(); + const memberCount = document.getElementById('memberCount').value; + const success = document.getElementById('success').value; + const pointAmount = document.getElementById('pointAmount').value; + const deadline = document.getElementById('deadline').value; + + // 필수 필드 검사 + if ( + !challengeName || + !description || + !memberCount || + !success || + !pointAmount || + !deadline + ) { + setError('비어있는 칸이 있습니다. 칸을 모두 채워주세요.'); + return; + } + + // Description 유효성 검사 + const descError = validateDescription(description); + if (descError) { + setDescriptionError(descError); + setError('설명 형식을 확인해주세요.'); + return; + } + + // 숫자 필드 검증 + const memberCountNum = parseInt(memberCount, 10); + const successNum = parseInt(success, 10); + const pointAmountNum = parseInt(pointAmount, 10); + const deadlineNum = parseInt(deadline, 10); + + if ( + memberCountNum < 0 || + successNum <= 0 || + pointAmountNum <= 0 || + deadlineNum <= 0 + ) { + setError('숫자 값을 올바르게 입력해주세요.'); + return; + } + + const data = { + challengeName, + description, + memberCount: memberCountNum, + success: successNum, + pointAmount: pointAmountNum, + deadline: deadlineNum, + }; + + setIsLoading(true); + + try { + const res = await api.post('/chalregis', data); + console.log('챌린지 추가 응답:', res.data); + + if (res.data.status === 'SUCCESS') { + alert('✅ 챌린지가 성공적으로 등록되었습니다!'); + // 폼 초기화 + document.getElementById('challengeName').value = ''; + document.getElementById('description').value = ''; + document.getElementById('memberCount').value = '0'; + document.getElementById('success').value = '50'; + document.getElementById('pointAmount').value = '500'; + document.getElementById('deadline').value = '7'; + setDescriptionError(''); + } else { + setError(res.data.message || '챌린지 추가에 실패했습니다.'); + } + } catch (err) { + console.error('챌린지 추가 실패', err.response || err); + + if (err.response?.status === 401) { + setError('❌ 인증이 필요합니다. 다시 로그인해주세요.'); + } else if (err.response?.status === 400) { + setError('❌ 입력 형식이 올바르지 않습니다.'); + } else if (err.response?.data?.message) { + setError(`❌ ${err.response.data.message}`); + } else { + setError('❌ 챌린지 추가 중 오류가 발생했습니다.'); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + + 챌린지 작성 + + + {/* 전역 에러 메시지 */} + {error && ( + + {error} + + )} + + + + + 챌린지명 + + + + + + + 설명 * + + + {/* Description 규칙 안내 */} + + 💡 설명은 따릉이,{' '} + 전기차, 수소차,{' '} + 재활용센터,{' '} + 제로웨이스트 중 하나로 시작해야 합니다. + + {/* Description 에러 메시지 */} + {descriptionError && ( + + {descriptionError} + + )} + + + + + 시작 인원수 + + + + 초기 참여 인원수는 0으로 고정됩니다. + + + + + + 성공 조건 + + + + + km / 원 + + + + 따릉이는 km, 충전/상점은 원(₩) 단위입니다. + + + + + + 지급 포인트 + + + + + + + 기한 + + + + 일 + + + + + + {isLoading ? ( + + + + + + 등록 중... + + ) : ( + '추가하기' + )} + + + + + {/* 챌린지 타입 안내 */} + + + 📋 챌린지 타입 안내 + + + + • 따릉이: 자전거 이용 챌린지 (거리 + 기준, km 단위) + + + • 전기차: 전기차 충전 챌린지 (충전비용 + 기준, 원 단위) + + + • 수소차: 수소차 충전 챌린지 (충전비용 + 기준, 원 단위) + + + • 재활용센터: 재활용센터 방문 챌린지 + (구매금액 기준, 원 단위) + + + • 제로웨이스트: 제로웨이스트 상점 이용 + 챌린지 (구매금액 기준, 원 단위) + + + + 💡 사용자가 인증을 완료하면 백엔드에서 자동으로 챌린지 + 진행률이 업데이트됩니다. + + + + ); +}; + +export default ChallengeForm; diff --git a/src/components/screens/AdminScreen.jsx b/src/components/screens/AdminScreen.jsx new file mode 100644 index 0000000..d46b192 --- /dev/null +++ b/src/components/screens/AdminScreen.jsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { setActiveTab } from '../../store/slices/appSlice'; +import BadgeForm from '../badge/BadgeForm'; +import ChallengeForm from '../challenge/ChallengeForm'; + +const AdminScreen = ({ onNavigate }) => { + const dispatch = useDispatch(); + const [activeTab, setActiveTab] = useState('badge'); + + const navigate = (tab) => { + if (typeof onNavigate === 'function') return onNavigate(tab); + dispatch(setActiveTab(tab)); + }; + + return ( + <> + {/* 뒤로가기 버튼이 있는 헤더 */} + + {/* 뒤로가기 버튼 */} + navigate('mypage')} + className='absolute left-4 top-1/2 -translate-y-1/2 p-2 hover:bg-white/20 rounded-full transition-colors' + aria-label='뒤로가기' + > + + + + + + {/* 제목 */} + + 관리자 페이지 + + 환영합니다 관리자님 👋 + + + + + + + setActiveTab('badge')} + > + 뱃지 추가 + + setActiveTab('challenge')} + > + 챌린지 추가 + + + {activeTab === 'badge' ? : } + + > + ); +}; + +export default AdminScreen; diff --git a/src/components/screens/MyPageScreen.jsx b/src/components/screens/MyPageScreen.jsx index 2d1efd4..b0b97b8 100644 --- a/src/components/screens/MyPageScreen.jsx +++ b/src/components/screens/MyPageScreen.jsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { setActiveTab } from '../../store/slices/appSlice'; import { fetchMyPageData, logout } from '../../store/slices/userSlice'; import { calculateEarnedBadges } from '../../store/slices/badgeSlice'; +import api from '../../api/axios'; const themeColor = '#96cb6f'; @@ -41,10 +42,41 @@ export default function MyPageScreen({ onNavigate }) { (s) => s.user ); const { allBadges, earnedIds } = useSelector((state) => state.badge); + const [isAdmin, setIsAdmin] = useState(false); const [showSetting, setShowSetting] = useState(false); const [showLogoutModal, setShowLogoutModal] = useState(false); // 로그아웃 모달 상태 + // 관리자 권한 확인 + const checkAdminStatus = async () => { + const token = localStorage.getItem('token'); + const memberId = localStorage.getItem('memberId'); + + // memberId가 1인 경우만 API 호출 + if (!token || memberId !== '1') { + setIsAdmin(false); + return; + } + + try { + const response = await api.get('/admin', { + headers: { Authorization: `Bearer ${token}` }, + }); + + if ( + response.data.status === 'SUCCESS' && + response.data.data.result + ) { + setIsAdmin(true); + } else { + setIsAdmin(false); + } + } catch (err) { + console.error('관리자 권한 확인 실패', err.response || err); + setIsAdmin(false); + } + }; + // 현재 획득한 최고 레벨 뱃지 찾기 const myBadge = useMemo(() => { const earnedBadges = allBadges.filter((badge) => @@ -64,6 +96,11 @@ export default function MyPageScreen({ onNavigate }) { dispatch(fetchMyPageData()); }, [dispatch]); + // 컴포넌트 마운트 시 관리자 권한 확인 + useEffect(() => { + checkAdminStatus(); + }, []); + useEffect(() => { if (stats.totalPoint !== undefined && stats.totalPoint !== null) { dispatch(calculateEarnedBadges(stats.totalPoint)); @@ -237,6 +274,20 @@ export default function MyPageScreen({ onNavigate }) { 메뉴 + {isAdmin && ( + + navigate('admin')} + className='w-full text-left px-4 py-4 rounded-xl hover:bg-green-50 transition-all text-green-700 flex items-center justify-between border border-green-200' + > + + 🛡️ + 관리자 + + → + + + )} navigate('point-exchange')} diff --git a/src/types/badge.d.ts b/src/types/badge.d.ts new file mode 100644 index 0000000..3c7b9ed --- /dev/null +++ b/src/types/badge.d.ts @@ -0,0 +1,23 @@ +export interface ApiResponse { + message: string; + data: T; +} + +export interface BadgeInfo { + name: string; + progress: number; + standard: number; + description: string; + image_url: string | null; + created_at: string; + isAcquired: boolean; + isSelected: boolean; +} + +export interface BadgeRequest { + categoryId: number; + name: string; + requirement: number; + description: string; + image_url: string; +}
+ 뱃지의 카테고리를 선택하세요. +
+ 이 뱃지를 획득하기 위해 필요한 포인트를 입력하세요. +
+ 뱃지 아이콘 이미지의 URL을 입력하세요. +
+ 💡 설명은 따릉이,{' '} + 전기차, 수소차,{' '} + 재활용센터,{' '} + 제로웨이스트 중 하나로 시작해야 합니다. +
+ {descriptionError} +
+ 초기 참여 인원수는 0으로 고정됩니다. +
+ 따릉이는 km, 충전/상점은 원(₩) 단위입니다. +
+ 💡 사용자가 인증을 완료하면 백엔드에서 자동으로 챌린지 + 진행률이 업데이트됩니다. +
+ 환영합니다 관리자님 👋 +