diff --git a/src/App.tsx b/src/App.tsx index 20107af..54999c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import EducationPage from '@pages/EducationPage'; import QuizHomePage from '@pages/QuizHomePage.tsx'; import ProfilePage from '@pages/ProfilePage'; import QuizPage from '@pages/QuizPage.tsx'; +import StatisticsPage from '@pages/StatisticsPage.tsx'; import { Global } from '@emotion/react'; import { globalStyles } from './styles/GlobalStyles'; @@ -20,6 +21,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/common/BottomNavBar.tsx b/src/components/common/BottomNavBar.tsx index 362b54d..4a9a763 100644 --- a/src/components/common/BottomNavBar.tsx +++ b/src/components/common/BottomNavBar.tsx @@ -19,6 +19,7 @@ const NavContainer = styled.nav` padding: 10px 0; border-top: 2px solid #4d403d; font-family: 'Noto Sans KR', sans-serif; + z-index: 100; `; const StyledNavLink = styled(NavLink)` @@ -60,6 +61,10 @@ const BottomNavBar = () => { quiz + + bar_chart + + person diff --git a/src/components/statistics/StatsCard.tsx b/src/components/statistics/StatsCard.tsx new file mode 100644 index 0000000..e84e37d --- /dev/null +++ b/src/components/statistics/StatsCard.tsx @@ -0,0 +1,60 @@ +import type { CardCategory } from '@@types/index'; +import styled from '@emotion/styled'; +import { CARD_CATEGORY_KO, categoryMaterialIcons } from '../../utils'; +import { GGAMJA_COLOR } from '../../styles/Colors'; + +const Container = styled.div` + background-color: white; + border: 2px solid ${GGAMJA_COLOR.DARK_BROWN}; + border-radius: 8px; + padding: 10px; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + aspect-ratio: 1 / 1; + box-shadow: 4px 4px 0 ${GGAMJA_COLOR.DARK_BROWN}; + gap: 5px; +`; + +const Icon = styled.span` + font-size: 30px; + color: ${GGAMJA_COLOR.DARK_BROWN}; +`; + +const CardTitle = styled.h3` + font-size: 20px; + font-weight: 700; + color: ${GGAMJA_COLOR.DARK_BROWN}; + margin: 8px 0 4px 0; +`; + +const StatsInfo = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + color: ${GGAMJA_COLOR.LIGHT_BROWN}; +`; + +interface StatsCardProps { + category: CardCategory; + quizLogCount: number; + cardLogCount: number; +} + +const StatsCard = ({ category, quizLogCount, cardLogCount }: StatsCardProps) => { + return ( + + {categoryMaterialIcons[category] || 'help'} + {CARD_CATEGORY_KO[category]} + +
퀴즈: {quizLogCount}회
+
학습: {cardLogCount}회
+
+
+ ); +}; + +export default StatsCard; diff --git a/src/components/statistics/StatsGrpah.tsx b/src/components/statistics/StatsGrpah.tsx new file mode 100644 index 0000000..db0eee9 --- /dev/null +++ b/src/components/statistics/StatsGrpah.tsx @@ -0,0 +1,97 @@ +import styled from '@emotion/styled'; +import { CARD_CATEGORY_KO } from '@utils/index'; +import type { CardCategory } from '@@types/index'; +import { GGAMJA_COLOR } from '../../styles/Colors'; + +interface CategoryData { + category: CardCategory; + accuracy: number; +} + +interface StatsGraphProps { + data: CategoryData[]; +} + +const Container = styled.div` + width: 100%; + height: 200px; + display: flex; + align-items: end; + justify-content: space-around; + gap: 8px; + padding: 20px 0; +`; + +const GraphItem = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + flex: 1; + height: 100%; +`; + +const BarContainer = styled.div` + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: end; +`; + +const CharacterImage = styled.img<{ barHeight: number }>` + width: 100%; + max-width: 30px; + height: 100%; + max-height: 30px; + object-fit: contain; + margin-bottom: ${({ barHeight }) => (barHeight === 0 ? '0px' : '-10px')}; + z-index: 1; +`; + +const Bar = styled.div<{ height: number }>` + width: 100%; + max-width: 20px; + background-color: ${GGAMJA_COLOR.GREEN}; + border-radius: 8px; + height: ${({ height }) => height}%; + transition: height 0.3s ease; + min-height: 0; +`; + +const CategoryName = styled.span` + font-size: 14px; + font-weight: 600; + color: ${GGAMJA_COLOR.DARK_BROWN}; + text-align: center; + text-orientation: mixed; +`; + +const AccuracyText = styled.span` + font-size: 12px; + font-weight: 500; + color: ${GGAMJA_COLOR.DARK_BROWN}; + text-align: center; +`; + +const StatsGraph = ({ data }: StatsGraphProps) => { + return ( + + {data.map((item, index) => ( + + + + + + {CARD_CATEGORY_KO[item.category]} + {item.accuracy}% + + ))} + + ); +}; + +export default StatsGraph; diff --git a/src/hooks/useMonthlyAnalysis.tsx b/src/hooks/useMonthlyAnalysis.tsx new file mode 100644 index 0000000..4f4942c --- /dev/null +++ b/src/hooks/useMonthlyAnalysis.tsx @@ -0,0 +1,14 @@ +import useApi from '@hooks/useApi'; +import type { ApiResponse, MonthlyAnalysisResponse } from '@@types/index.ts'; + +function useMonthlyAnalysis() { + const { api } = useApi(); + + const fetchMonthlyAnalysis = () => { + return api.get>(`/analysis/categories/monthly`).then((response) => response.data.data); + }; + + return { fetchMonthlyAnalysis }; +} + +export default useMonthlyAnalysis; diff --git a/src/pages/StatisticsPage.tsx b/src/pages/StatisticsPage.tsx new file mode 100644 index 0000000..b013310 --- /dev/null +++ b/src/pages/StatisticsPage.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react'; +import BottomNavBar from '@components/common/BottomNavBar.tsx'; +import StatsCard from '@components/statistics/StatsCard.tsx'; +import StatsGraph from '@components/statistics/StatsGrpah.tsx'; +import useMonthlyAnalysis from '@hooks/useMonthlyAnalysis.tsx'; +import styled from '@emotion/styled'; +import { GGAMJA_COLOR } from '../styles/Colors.ts'; +import type { CardCategory } from '@@types/index.ts'; +import { CARD_CATEGORY_KO } from '@utils/index.ts'; + +const PageWrapper = styled.div` + background-color: #faf8f5; + color: ${GGAMJA_COLOR.DARK_BROWN}; + padding-bottom: 80px; + height: 100%; + overflow: auto; +`; + +const Container = styled.div` + padding: 40px 20px 20px 20px; +`; + +const Header = styled.header` + text-align: center; +`; + +const PageTitle = styled.h1` + font-size: 36px; + font-weight: 700; + color: ${GGAMJA_COLOR.DARK_BROWN}; +`; + +const StatisticsContentWrapper = styled.div` + border: 1px solid ${GGAMJA_COLOR.DARK_BROWN}; + border-radius: 10px; + padding: 40px 20px 40px 20px; + display: flex; + flex-direction: column; + align-items: center; + aspect-ratio: 1 / 1; + box-shadow: 4px 4px 0 ${GGAMJA_COLOR.DARK_BROWN}; + gap: 10px; +`; + +const StatsCardGridContainer = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 20px; + width: 100%; + align-items: center; +`; + +const StatisticsContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + border: 1px solid ${GGAMJA_COLOR.DARK_BROWN}; + border-radius: 10px; + gap: 10px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 4px 4px 0 ${GGAMJA_COLOR.DARK_BROWN}; +`; + +const StatisticsTitle = styled.h2` + font-size: 24px; + font-weight: 700; + width: 100%; + color: ${GGAMJA_COLOR.DARK_BROWN}; + text-align: left; +`; + +const StatisticsTextContainer = styled.div<{ isSurpassed: boolean }>` + font-size: 20px; + font-weight: 400; + width: 100%; + color: ${GGAMJA_COLOR.DARK_BROWN}; +`; + +const StatisticsCategory = styled.h3` + display: inline; + font-weight: 700; +`; + +const StatisticsPage = () => { + const { fetchMonthlyAnalysis } = useMonthlyAnalysis(); + const [nickname, setNickname] = useState('사용자'); + const [categories, setCategories] = useState<{ category: CardCategory; quizLogCount: number; cardLogCount: number; accuracy: number }[]>([]); + const [strengths, setStrengths] = useState([]); + const [weaknesses, setWeaknesses] = useState([]); + const [allEqual, setAllEqual] = useState(false); + + useEffect(() => { + const loadStatistics = async () => { + const statisticsData = await fetchMonthlyAnalysis(); + setNickname(statisticsData.nickname); + setCategories(statisticsData.categories); + setStrengths(statisticsData.strengths); + setWeaknesses(statisticsData.weaknesses); + setAllEqual(statisticsData.allEqual); + }; + + loadStatistics(); + }, []); + + const renderStatisticsContent = () => { + if (!allEqual) { + return [ + { data: strengths, isSurpassed: true, message: '분야에 강하시네요!' }, + { data: weaknesses, isSurpassed: false, message: '분야를 조금 더 공부해보아요!' }, + ].map( + ({ data, isSurpassed, message }) => + data.length > 0 && ( + + {data.map((category, index) => ( + + {CARD_CATEGORY_KO[category as CardCategory]} + {index < data.length - 1 ? ', ' : ''} + + ))} + {message} + + ), + ); + } + const isNeverStudied = categories[0].accuracy == 0; + return isNeverStudied ? '지금 당장 공부하러 가볼까요?' : '모든 카테고리의 정답률이 동일합니다.'; + }; + + return ( + + +
+ 이번 달 통계 +
+ + 카테고리 분석 + + + {categories.map((item, index) => ( + + ))} + + + 정답률 분석 + + + + {nickname}님, 이번 달에는 ... + {renderStatisticsContent()} + + +
+
+ ); +}; + +export default StatisticsPage; diff --git a/src/types/index.ts b/src/types/index.ts index e55f423..ade6dbc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -140,3 +140,18 @@ export interface AttendanceResponse { ]; allCompleted: boolean; } + +export interface MonthlyAnalysisResponse { + nickname: string; + categories: [ + { + category: CardCategory; + quizLogCount: number; + cardLogCount: number; + accuracy: number; + }, + ]; + strengths: CardCategory[]; + weaknesses: CardCategory[]; + allEqual: boolean; +}