diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index ec2f48296..e319a369f 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -27,6 +27,7 @@ "jest.polyfills.js" ], "rules": { + "no-console": "error", "react/react-in-jsx-scope": "off", "@typescript-eslint/no-unused-vars": "warn", "react/no-unknown-property": ["error", { "ignore": ["css"] }], diff --git a/frontend/.stylelintrc.json b/frontend/.stylelintrc.json index 74c4003b1..53447c391 100644 --- a/frontend/.stylelintrc.json +++ b/frontend/.stylelintrc.json @@ -27,9 +27,12 @@ "bottom", "left", "z-index", + "flex", + "flex-basis", "flex-direction", "justify-content", - "align-items" + "align-items", + "gap" ] }, { @@ -49,7 +52,10 @@ "padding-right", "padding-bottom", "padding-left", - "border" + "border", + "border-radius", + "border-top", + "border-bottom" ] }, { diff --git a/frontend/src/apis/balanceContent.ts b/frontend/src/apis/balanceContent.ts index c000cb598..6f434d321 100644 --- a/frontend/src/apis/balanceContent.ts +++ b/frontend/src/apis/balanceContent.ts @@ -1,7 +1,8 @@ import fetcher from './fetcher'; import { API_URL } from '@/constants/url'; -import { BalanceContent } from '@/types/balanceContent'; +import { BalanceContent, GameFinalResult } from '@/types/balanceContent'; +import { RoundVoteResult } from '@/types/roundVoteResult'; export const fetchBalanceContent = async (roomId = 1): Promise => { const res = await fetcher.get({ url: API_URL.balanceContent(roomId) }); @@ -24,3 +25,33 @@ export const voteBalanceContent = async (optionId: number, roomId = 1, contentId return data; }; + +export const fetchRoundVoteResult = async (roomId = 1, contentId = 1): Promise => { + const res = await fetcher.get({ + url: API_URL.roundVoteResult(roomId, contentId), + }); + + const data = await res.json(); + + return data; +}; + +export const moveNextRound = async (roomId = 1): Promise => { + const res = await fetcher.post({ + url: API_URL.moveNextRound(roomId), + }); + + const data = await res.json(); + + return data; +}; + +export const fetchFinalGameResult = async (roomId = 1): Promise => { + const res = await fetcher.get({ + url: API_URL.finalResult(roomId), + }); + + const data = await res.json(); + + return data; +}; diff --git a/frontend/src/components/GameResult/GameResult.hook.ts b/frontend/src/components/GameResult/GameResult.hook.ts new file mode 100644 index 000000000..a8d88f668 --- /dev/null +++ b/frontend/src/components/GameResult/GameResult.hook.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchFinalGameResult } from '@/apis/balanceContent'; + +const useGameResultQuery = () => { + const gameResultQuery = useQuery({ + queryKey: ['gameResult'], + queryFn: async () => await fetchFinalGameResult(), + }); + + return { ...gameResultQuery, gameResult: gameResultQuery.data }; +}; + +export { useGameResultQuery }; diff --git a/frontend/src/components/GameResult/GameResult.styled.ts b/frontend/src/components/GameResult/GameResult.styled.ts index 5b6951d55..c72361101 100644 --- a/frontend/src/components/GameResult/GameResult.styled.ts +++ b/frontend/src/components/GameResult/GameResult.styled.ts @@ -1,20 +1,24 @@ import { css } from '@emotion/react'; +import { Theme } from '@/styles/Theme'; + export const gameResultLayout = css` display: flex; flex-direction: column; justify-content: center; align-items: center; - height: 40vh; - gap: 6rem; + gap: 4.8rem; + width: 100%; `; -export const gameResultTitleWrapper = css` - display: flex; - flex-basis: 20%; +export const gameResultTitle = css` + ${Theme.typography.slogan}; `; -export const gameResultTitle = css` - font-weight: bold; - font-size: 2.4rem; +export const rankListContainer = css` + display: flex; + flex-basis: 60%; + flex-direction: column; + gap: 2rem; + width: 100%; `; diff --git a/frontend/src/components/GameResult/GameResult.tsx b/frontend/src/components/GameResult/GameResult.tsx index cb21e4efe..1ad6f85e7 100644 --- a/frontend/src/components/GameResult/GameResult.tsx +++ b/frontend/src/components/GameResult/GameResult.tsx @@ -1,23 +1,22 @@ -import { useNavigate } from 'react-router-dom'; - -import { gameResultTitle, gameResultTitleWrapper, gameResultLayout } from './GameResult.styled'; - -import Button from '@/components/common/Button/Button'; +import { useGameResultQuery } from './GameResult.hook'; +import { gameResultTitle, gameResultLayout, rankListContainer } from './GameResult.styled'; +import FinalButton from '../common/FinalButton/FinalButton'; +import GameResultItem from '../GameResultItem/GameResultItem'; const GameResult = () => { - const navigate = useNavigate(); - - const goToHome = () => { - navigate('/'); - }; + const { gameResult } = useGameResultQuery(); return ( -
-
+ <> +

게임 결과

+
+ {gameResult && + gameResult.map((item) => )} +
-
+ + ); }; diff --git a/frontend/src/components/GameResultItem/GameResultItem.styled.ts b/frontend/src/components/GameResultItem/GameResultItem.styled.ts new file mode 100644 index 000000000..909f92e63 --- /dev/null +++ b/frontend/src/components/GameResultItem/GameResultItem.styled.ts @@ -0,0 +1,42 @@ +import { css } from '@emotion/react'; + +import { Theme } from '@/styles/Theme'; + +export const rankItem = css` + display: flex; + justify-content: space-between; + align-items: center; + ${Theme.typography.headline3}; +`; + +export const rankInfoContainer = css` + display: flex; + flex-basis: 85%; + align-items: center; + gap: 1.2rem; +`; + +export const rankNumber = css` + ${Theme.typography.headline1} +`; + +export const nicknameContainer = (percent: number) => css` + display: flex; + overflow: visible; + gap: 1rem; + width: ${percent > 5 ? percent - 5 : percent}%; + height: 100%; + padding: 1.2rem; + border-radius: ${Theme.borderRadius.radius20}; + + background-color: ${Theme.color.peanut400}; + transition: all 2s; +`; + +export const rankPercent = css` + ${Theme.typography.headline3} +`; + +export const nickname = css` + min-width: 5.6rem; +`; diff --git a/frontend/src/components/GameResultItem/GameResultItem.tsx b/frontend/src/components/GameResultItem/GameResultItem.tsx new file mode 100644 index 000000000..dd8b2c683 --- /dev/null +++ b/frontend/src/components/GameResultItem/GameResultItem.tsx @@ -0,0 +1,36 @@ +import { + nickname, + nicknameContainer, + rankInfoContainer, + rankItem, + rankNumber, + rankPercent, +} from './GameResultItem.styled'; + +import useCountAnimation from '@/hooks/useCountAnimation'; +import { GameFinalResult } from '@/types/balanceContent'; + +interface GameResultItemProps { + gameFinalResult: GameFinalResult; +} +const GameResultItem = ({ gameFinalResult }: GameResultItemProps) => { + const animatedRankPercent = useCountAnimation({ + target: gameFinalResult.percent, + duration: 3000, + }); + + return ( +
+
+ {gameFinalResult.rank} +
+ 🥜 + {gameFinalResult.name} +
+
+ {animatedRankPercent}% +
+ ); +}; + +export default GameResultItem; diff --git a/frontend/src/components/RoundResultTab/RoundResultTab.styled.ts b/frontend/src/components/RoundResultTab/RoundResultTab.styled.ts new file mode 100644 index 000000000..783f01104 --- /dev/null +++ b/frontend/src/components/RoundResultTab/RoundResultTab.styled.ts @@ -0,0 +1,15 @@ +import { css } from '@emotion/react'; + +import { Theme } from '@/styles/Theme'; + +export const tabButtonStyle = (isActive: boolean) => css` + flex: 1; + padding: 0.8rem; + border-radius: 1.2rem 1.2rem 0 0; + + background-color: ${isActive ? Theme.color.peanut400 : Theme.color.gray}; + + color: black; + font-weight: bold; + transition: all 0.5s; +`; diff --git a/frontend/src/components/RoundResultTab/RoundResultTab.tsx b/frontend/src/components/RoundResultTab/RoundResultTab.tsx new file mode 100644 index 000000000..2a4823a08 --- /dev/null +++ b/frontend/src/components/RoundResultTab/RoundResultTab.tsx @@ -0,0 +1,23 @@ +import { tabButtonStyle } from './RoundResultTab.styled'; + +type TabTitle = 'group' | 'total'; + +interface RoundResultTabProps { + tab: TabTitle; + activeTab: TabTitle; + handleClickTab: (tab: TabTitle) => void; +} + +const TAB_TITLE = { + group: '그룹', + total: '전체', +} as const; + +const RoundResultTab = ({ tab, activeTab, handleClickTab }: RoundResultTabProps) => { + return ( + + ); +}; +export default RoundResultTab; diff --git a/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts new file mode 100644 index 000000000..5e5672d8c --- /dev/null +++ b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts @@ -0,0 +1,21 @@ +import useCountAnimation from '@/hooks/useCountAnimation'; +import { Total, Group } from '@/types/roundVoteResult'; + +export const useTotalCountAnimation = (groupRoundResult?: Group, totalResult?: Total) => { + const animatedFirstPercent = useCountAnimation({ target: groupRoundResult?.firstOption.percent }); + const animatedSecondPercent = useCountAnimation({ + target: groupRoundResult?.secondOption.percent, + }); + + const animatedTotalFirstPercent = useCountAnimation({ target: totalResult?.firstOption.percent }); + const animatedTotalSecondPercent = useCountAnimation({ + target: totalResult?.secondOption.percent, + }); + + return { + animatedFirstPercent, + animatedSecondPercent, + animatedTotalFirstPercent, + animatedTotalSecondPercent, + }; +}; diff --git a/frontend/src/components/RoundVoteContainer/RoundVoteContainer.styled.ts b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.styled.ts new file mode 100644 index 000000000..6c84c91df --- /dev/null +++ b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.styled.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/react'; + +export const tabLayout = css` + display: flex; + flex-basis: 45%; + flex-direction: column; + width: 100%; + + transition: all 1s; +`; + +export const tabWrapperStyle = css` + display: flex; + width: 40%; + margin-left: 2.4rem; +`; diff --git a/frontend/src/components/RoundVoteContainer/RoundVoteContainer.tsx b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.tsx new file mode 100644 index 000000000..1cd0a3fbc --- /dev/null +++ b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; + +import { useTotalCountAnimation } from './RoundVoteContainer.hook'; +import { tabLayout, tabWrapperStyle } from './RoundVoteContainer.styled'; +import RoundResultTab from '../RoundResultTab/RoundResultTab'; +import TabContentContainer from '../TabContentContainer/TabContentContainer'; + +import useRoundVoteResultQuery from '@/hooks/useRoundVoteResultQuery'; + +const RoundVoteContainer = () => { + const [activeTab, setActiveTab] = useState<'group' | 'total'>('group'); + const isGroupTabActive = activeTab === 'group'; + + const { groupRoundResult, totalResult } = useRoundVoteResultQuery(); + const { + animatedFirstPercent, + animatedSecondPercent, + animatedTotalFirstPercent, + animatedTotalSecondPercent, + } = useTotalCountAnimation(groupRoundResult, totalResult); + + const handleClickTab = (clickedTab: 'group' | 'total') => { + setActiveTab(clickedTab); + }; + + if (!groupRoundResult || !totalResult) return
데이터가 없습니다
; + + return ( +
+
+ + +
+ +
+ ); +}; + +export default RoundVoteContainer; diff --git a/frontend/src/components/RoundVoteResult/RoundVoteResult.styled.ts b/frontend/src/components/RoundVoteResult/RoundVoteResult.styled.ts deleted file mode 100644 index 670869b95..000000000 --- a/frontend/src/components/RoundVoteResult/RoundVoteResult.styled.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { css } from '@emotion/react'; - -import { Theme } from '@/styles/Theme'; - -interface RoundVoteResultLayoutProps { - percentage: number; -} - -export const roundVoteResultLayout = ({ percentage }: RoundVoteResultLayoutProps) => css` - display: flex; - justify-content: space-around; - align-items: center; - width: 24rem; - height: 11.6rem; - - font-size: 1.2rem; - background: linear-gradient( - to right, - ${Theme.color.peanut500} 0%, - ${Theme.color.peanut500} ${percentage - 20}%, - ${Theme.color.peanut300} ${percentage}%, - white 100% - ); - - border-radius: 1.7rem; -`; - -export const voteContent = css` - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; -`; - -export const fontBold = css` - font-weight: bold; -`; diff --git a/frontend/src/components/RoundVoteResult/RoundVoteResult.tsx b/frontend/src/components/RoundVoteResult/RoundVoteResult.tsx deleted file mode 100644 index 634ac52e8..000000000 --- a/frontend/src/components/RoundVoteResult/RoundVoteResult.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { roundVoteResultLayout, fontBold, voteContent } from './RoundVoteResult.styled'; - -import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; - -const RoundVoteResult = () => { - const { balanceContent } = useBalanceContentQuery(); - - return ( -
-
-
{balanceContent?.firstOption.name}
-
72%
-
- vs -
-
{balanceContent?.secondOption.name}
-
28%
-
-
- ); -}; - -export default RoundVoteResult; diff --git a/frontend/src/components/SelectContainer/SelectContainer.styled.ts b/frontend/src/components/SelectContainer/SelectContainer.styled.ts index 1a134def7..1938d242e 100644 --- a/frontend/src/components/SelectContainer/SelectContainer.styled.ts +++ b/frontend/src/components/SelectContainer/SelectContainer.styled.ts @@ -2,11 +2,11 @@ import { css } from '@emotion/react'; export const selectContainerLayout = css` display: flex; + flex-basis: 55%; flex-direction: column; justify-content: center; align-items: center; - gap: 4rem; - flex-basis: 55%; + gap: 5rem; `; export const selectSection = css` diff --git a/frontend/src/components/SelectContainer/SelectContainer.tsx b/frontend/src/components/SelectContainer/SelectContainer.tsx index b5e96e094..a873db461 100644 --- a/frontend/src/components/SelectContainer/SelectContainer.tsx +++ b/frontend/src/components/SelectContainer/SelectContainer.tsx @@ -13,7 +13,7 @@ const SelectContainer = () => { const [selectedId, setSelectedId] = useState(0); const goToRoundResult = () => { - navigate('/round-result'); + navigate(`/round/result`); }; const handleSelectOption = (selectedId: number) => { @@ -39,7 +39,8 @@ const SelectContainer = () => { handleSelectOption={handleSelectOption} /> -
)} diff --git a/frontend/src/components/SelectOption/SelectOption.styled.ts b/frontend/src/components/SelectOption/SelectOption.styled.ts index c5b03e0a4..32c525f17 100644 --- a/frontend/src/components/SelectOption/SelectOption.styled.ts +++ b/frontend/src/components/SelectOption/SelectOption.styled.ts @@ -9,6 +9,7 @@ export const SelectOptionLayout = (selected: boolean) => css` width: 11.6rem; height: 16.8rem; padding: 1.6rem; + border-radius: 3rem; background-color: ${selected ? Theme.color.peanut500 : Theme.color.peanut300}; @@ -19,7 +20,6 @@ export const SelectOptionLayout = (selected: boolean) => css` text-align: center; word-break: keep-all; - border-radius: 3rem; transition: all 0.5s; scale: ${selected ? 1.1 : 1}; diff --git a/frontend/src/components/TabContentContainer/TabContentContainer.styled.ts b/frontend/src/components/TabContentContainer/TabContentContainer.styled.ts new file mode 100644 index 000000000..3230b1adc --- /dev/null +++ b/frontend/src/components/TabContentContainer/TabContentContainer.styled.ts @@ -0,0 +1,108 @@ +import { css } from '@emotion/react'; + +import { Theme } from '@/styles/Theme'; + +export const contentWrapperStyle = css` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 2.4rem; + border: 0.3rem solid ${Theme.color.peanut400}; + border-radius: 0.8rem; +`; + +export const alertText = (isGroupTabActive: boolean) => css` + display: flex; + visibility: ${isGroupTabActive ? 'hidden' : 'visible'}; + justify-content: center; + width: 100%; + height: 1.2rem; + + ${Theme.typography.body1} + font-weight: bold; +`; + +export const roundVoteResultContainer = css` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const categoryContainer = css` + display: flex; + justify-content: space-between; + + font-weight: bold; + font-size: 1.4rem; +`; + +export const barWrapperStyle = css` + display: flex; + align-items: center; + width: inherit; +`; + +export const barStyle = (percentage: number, isBigFirstOption: boolean) => css` + display: flex; + justify-content: center; + align-items: center; + + width: ${percentage}%; + height: 8vh; + border-radius: 1.6rem 0 0 1.6rem; + + background-color: ${isBigFirstOption ? Theme.color.peanut400 : Theme.color.gray}; + + color: black; + font-weight: bold; + font-size: 1.6rem; + clip-path: polygon(0 0, 100% 0, calc(100% - 10px) 100%, 0 100%); + transition: all 1s; + transform: translateX(5px); +`; + +export const barBackgroundStyle = (percentage: number, isBigFirstOption: boolean) => css` + display: flex; + justify-content: center; + align-items: center; + width: ${percentage}%; + height: 8vh; + border-radius: 0 1.6rem 1.6rem 0; + + background-color: ${isBigFirstOption ? Theme.color.gray : Theme.color.peanut400}; + + color: black; + font-weight: bold; + font-size: 1.6rem; + clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%); + transition: all 1s; + transform: translateX(-5px); +`; + +export const resultTextStyle = (isActiveGroupTab: boolean) => css` + display: flex; + visibility: ${isActiveGroupTab ? 'visible' : 'hidden'}; + justify-content: space-between; + align-items: center; + height: 1.2rem; + + font-weight: bold; + font-size: 1.2rem; +`; + +export const currentVoteButtonWrapper = (isGroupTabActive: boolean) => css` + display: flex; + visibility: ${isGroupTabActive ? 'visible' : 'hidden'}; + justify-content: flex-end; + align-items: center; +`; + +export const buttonStyle = css` + color: black; + font-weight: bold; + + &:active { + opacity: 0.7; + } +`; diff --git a/frontend/src/components/TabContentContainer/TabContentContainer.tsx b/frontend/src/components/TabContentContainer/TabContentContainer.tsx new file mode 100644 index 000000000..4ccbdc2fe --- /dev/null +++ b/frontend/src/components/TabContentContainer/TabContentContainer.tsx @@ -0,0 +1,72 @@ +import { useNavigate } from 'react-router-dom'; + +import { + alertText, + barBackgroundStyle, + barStyle, + barWrapperStyle, + buttonStyle, + categoryContainer, + contentWrapperStyle, + currentVoteButtonWrapper, + resultTextStyle, + roundVoteResultContainer, +} from './TabContentContainer.styled'; + +import { Group, Total } from '@/types/roundVoteResult'; + +interface TabContentContainerProps { + isGroupTabActive: boolean; + roundResult: Group | Total; + animatedFirstPercent?: number; + animatedSecondPercent?: number; +} + +const TabContentContainer = ({ + isGroupTabActive, + roundResult, + animatedFirstPercent, + animatedSecondPercent, +}: TabContentContainerProps) => { + const navigate = useNavigate(); + + const isBigFirstOption = roundResult.firstOption.percent >= 50; + + const goToCurrentVote = () => { + navigate('/round/result/current'); + }; + return ( +
+
다른 사람들은 이렇게 생각했어요 🥜
+
+
+ {roundResult.firstOption.name} + {roundResult.secondOption.name} +
+
+ + {animatedFirstPercent}% + + + {animatedSecondPercent}% + +
+
+ {'memberCount' in roundResult.firstOption && ( + {roundResult.firstOption.memberCount}명 + )} + {'memberCount' in roundResult.secondOption && ( + {roundResult.secondOption.memberCount}명 + )} +
+
+
+ +
+
+ ); +}; + +export default TabContentContainer; diff --git a/frontend/src/components/Timer/Timer.styled.ts b/frontend/src/components/Timer/Timer.styled.ts index 331ee3842..40f10dea2 100644 --- a/frontend/src/components/Timer/Timer.styled.ts +++ b/frontend/src/components/Timer/Timer.styled.ts @@ -4,12 +4,12 @@ import { Theme } from '@/styles/Theme'; export const timerLayout = css` display: flex; + flex-basis: 5%; justify-content: center; width: 100%; height: 3.2rem; - flex-basis: 5%; + border: none; background: linear-gradient(to right, ${Theme.color.peanut500}, ${Theme.color.peanut300}); - border: none; border-radius: 1.7rem; `; diff --git a/frontend/src/components/TopicContainer/TopicContainer.styled.ts b/frontend/src/components/TopicContainer/TopicContainer.styled.ts index 5906fc9e8..af1e27df1 100644 --- a/frontend/src/components/TopicContainer/TopicContainer.styled.ts +++ b/frontend/src/components/TopicContainer/TopicContainer.styled.ts @@ -2,11 +2,11 @@ import { css } from '@emotion/react'; export const topicContainerLayout = css` display: flex; + flex-basis: 20%; flex-direction: column; justify-content: center; align-items: center; gap: 2rem; - flex-basis: 20%; `; export const categoryText = css` diff --git a/frontend/src/components/TopicContainer/TopicContainer.tsx b/frontend/src/components/TopicContainer/TopicContainer.tsx index 0c55d196f..f84d60ac6 100644 --- a/frontend/src/components/TopicContainer/TopicContainer.tsx +++ b/frontend/src/components/TopicContainer/TopicContainer.tsx @@ -1,13 +1,19 @@ +import { useLocation } from 'react-router-dom'; + import { categoryText, topicContainerLayout, topicText } from './TopicContainer.styled'; +import { ROUTES } from '@/constants/routes'; import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; const TopicContainer = () => { const { balanceContent } = useBalanceContentQuery(); + const location = useLocation(); + + const isGamePage = location.pathname === ROUTES.game; return (
- {balanceContent?.category} + {isGamePage && balanceContent?.category} {balanceContent?.question}
); diff --git a/frontend/src/components/common/Button/Button.styled.ts b/frontend/src/components/common/Button/Button.styled.ts index f91662fe5..d4a85e138 100644 --- a/frontend/src/components/common/Button/Button.styled.ts +++ b/frontend/src/components/common/Button/Button.styled.ts @@ -31,3 +31,9 @@ export const buttonLayout = ({ disabled, size, radius, fontSize }: ButtonLayoutP background-color: ${Theme.color.peanut300}; } `; + +export const bottomButtonLayout = css` + position: fixed; + bottom: 0; + width: 100%; +`; diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index f9f338e2f..893bbcfb8 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -22,7 +22,12 @@ const Button: React.FC = ({ ...props }) => { return ( - ); diff --git a/frontend/src/components/common/FinalButton/FinalButton.tsx b/frontend/src/components/common/FinalButton/FinalButton.tsx new file mode 100644 index 000000000..e3fd781e2 --- /dev/null +++ b/frontend/src/components/common/FinalButton/FinalButton.tsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom'; + +import Button from '../Button/Button'; +import { bottomButtonLayout } from '../Button/Button.styled'; + +const FinalButton = () => { + const navigate = useNavigate(); + + const goToHome = () => { + navigate('/ready'); + }; + + return ( +
+
+ ); +}; + +export default FinalButton; diff --git a/frontend/src/components/common/NextRoundButton/NextRoundButton.hook.ts b/frontend/src/components/common/NextRoundButton/NextRoundButton.hook.ts new file mode 100644 index 000000000..5fcb75061 --- /dev/null +++ b/frontend/src/components/common/NextRoundButton/NextRoundButton.hook.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { moveNextRound } from '@/apis/balanceContent'; + +const useMoveNextRoundMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => await moveNextRound(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['balanceContent'] }); + }, + }); +}; + +export { useMoveNextRoundMutation }; diff --git a/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx b/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx new file mode 100644 index 000000000..531cd2750 --- /dev/null +++ b/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from 'react-router-dom'; + +import { useMoveNextRoundMutation } from './NextRoundButton.hook'; +import Button from '../Button/Button'; +import { bottomButtonLayout } from '../Button/Button.styled'; + +import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; + +const NextRoundButton = () => { + const navigate = useNavigate(); + const { balanceContent } = useBalanceContentQuery(); + const { mutateAsync: moveNextRound } = useMoveNextRoundMutation(); + + const isLastRound = balanceContent?.currentRound === balanceContent?.totalRound; + + const goToGameResult = () => { + navigate('/game/result'); + }; + + const goToNextRound = async () => { + await moveNextRound(); + navigate('/game'); + }; + + return ( +
+
+ ); +}; + +export default NextRoundButton; diff --git a/frontend/src/components/layout/Content/Content.styled.ts b/frontend/src/components/layout/Content/Content.styled.ts index a1d90f77d..1d6831557 100644 --- a/frontend/src/components/layout/Content/Content.styled.ts +++ b/frontend/src/components/layout/Content/Content.styled.ts @@ -4,6 +4,6 @@ export const contentLayout = css` display: flex; flex-direction: column; align-items: center; - height: inherit; gap: 1.6rem; + height: inherit; `; diff --git a/frontend/src/components/layout/Header/Header.styled.ts b/frontend/src/components/layout/Header/Header.styled.ts index 0e19358f1..97c0f8738 100644 --- a/frontend/src/components/layout/Header/Header.styled.ts +++ b/frontend/src/components/layout/Header/Header.styled.ts @@ -7,6 +7,14 @@ export const headerLayout = css` height: 8rem; `; +export const roundText = css` + width: 2.4rem; + height: 2.4rem; + + font-weight: bold; + font-size: 1.6rem; +`; + export const gameTitle = css` font-weight: bold; font-size: 1.6rem; diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx index 026d6331f..599c81c29 100644 --- a/frontend/src/components/layout/Header/Header.tsx +++ b/frontend/src/components/layout/Header/Header.tsx @@ -1,29 +1,46 @@ -import { useNavigate } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; -import { gameTitle, headerLayout } from './Header.styled'; +import { gameTitle, headerLayout, roundText } from './Header.styled'; -import HomeIcon from '@/assets/images/homeIcon.svg'; -import SettingsIcon from '@/assets/images/settingsIcon.svg'; +import { ROUTES } from '@/constants/routes'; +import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; interface HeaderProps { title: string; } +// TODO: Header 분리 +// 1. 공간만 차지하는 빈 헤더 : 게임 결과 화면 +// 2. 가운데 제목만 차지하는 헤더 : 닉네임 설정 화면, 게임 대기 화면 +// 3. 좌측 상단과 가운데 제목 차지하는 헤더 : 게임 화면, 라운드 통계 화면, 라운드 투표 현황 const Header = ({ title }: HeaderProps) => { - const navigate = useNavigate(); + const { balanceContent } = useBalanceContentQuery(); + const location = useLocation(); + + const isRoundResultPage = location.pathname === ROUTES.roundResult; + const isFinalPage = location.pathname === ROUTES.gameResult; + + if (isFinalPage) { + return
; + } + + if (isRoundResultPage) { + return ( +
+ + 투표 결과 + +
+ ); + } - const goToHome = () => { - navigate('/'); - }; return (
- + + {balanceContent ? `${balanceContent.currentRound}/${balanceContent.totalRound}` : '1/5'} + {title} - +
); }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts new file mode 100644 index 000000000..4c33a3564 --- /dev/null +++ b/frontend/src/constants/routes.ts @@ -0,0 +1,9 @@ +export const ROUTES = { + main: '/', + nickname: '/nickname', + ready: '/ready', + game: '/game', + roundResult: '/round/result', + roundResultVote: '/round/result/vote', + gameResult: '/game/result', +} as const; diff --git a/frontend/src/constants/url.ts b/frontend/src/constants/url.ts index 0200c3a81..ca92cbf3c 100644 --- a/frontend/src/constants/url.ts +++ b/frontend/src/constants/url.ts @@ -4,9 +4,16 @@ export const API_URL = { balanceContent: (roomId: number) => `${BASE_URL}/api/balances/rooms/${roomId}/content`, vote: (roomId: number, contentId: number) => `${BASE_URL}/api/balances/rooms/${roomId}/contents/${contentId}/votes`, + roundVoteResult: (roomId: number, contentId: number) => + `${BASE_URL}/api/balances/rooms/${roomId}/contents/${contentId}/vote-result`, + moveNextRound: (roomId: number) => `${BASE_URL}/api/balances/rooms/${roomId}/contents`, + finalResult: (roomId: number) => `${BASE_URL}/api/balances/rooms/${roomId}/final`, }; export const MOCK_API_URL = { balanceContent: '/api/balances/rooms/:roomId/content', vote: '/api/balances/rooms/:roomId/contents/:contentId/votes', + roundVoteResult: '/api/balances/rooms/:roomId/contents/:contentId/vote-result', + moveNextRound: '/api/balances/rooms/:roomId/contents', + finalResult: '/api/balances/rooms/:roomId/final', }; diff --git a/frontend/src/hooks/useCountAnimation.ts b/frontend/src/hooks/useCountAnimation.ts new file mode 100644 index 000000000..e638e270b --- /dev/null +++ b/frontend/src/hooks/useCountAnimation.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +// "ease out" : 빨라졌다가 천천히 끝나는 애니메이션 +const easeOutRate = (timingRate: number) => { + return timingRate === 1 ? 1 : 1 - Math.pow(2, -10 * timingRate); +}; + +interface UseCountAnimationProps { + target?: number; + start?: number; + duration?: number; +} + +// start에서 target으로 숫자 카운팅되는 애니메이션 커스텀 훅 +const useCountAnimation = ({ target, start = 50, duration = 2000 }: UseCountAnimationProps) => { + const [count, setCount] = useState(start); + const frameRate = 500 / 60; + const totalFrame = Math.round(duration / frameRate); + + useEffect(() => { + // target 값을 API로 불러올 경우 초기값이 애니메이션에 반영되므로 예외처리 + if (typeof target === 'undefined' || target === start) return; + let currentNumber = start; + const counter = setInterval(() => { + const progress = easeOutRate(++currentNumber / totalFrame); + setCount(Math.round(target * progress)); + + if (progress === 1) { + clearInterval(counter); + } + }, frameRate); + }, [target, frameRate, start, totalFrame]); + + return count; +}; + +export default useCountAnimation; diff --git a/frontend/src/hooks/useRoundVoteResultQuery.ts b/frontend/src/hooks/useRoundVoteResultQuery.ts new file mode 100644 index 000000000..f915dc065 --- /dev/null +++ b/frontend/src/hooks/useRoundVoteResultQuery.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; + +import INITIAL_VALUE from '../mocks/data/roundVoteResultInitialValue.json'; + +import { fetchRoundVoteResult } from '@/apis/balanceContent'; + +const useRoundVoteResultQuery = () => { + const roundVoteResultQuery = useQuery({ + queryKey: ['roundVoteResult'], + queryFn: async () => await fetchRoundVoteResult(), + placeholderData: INITIAL_VALUE, + }); + + return { + ...roundVoteResultQuery, + groupRoundResult: roundVoteResultQuery.data?.group, + totalResult: roundVoteResultQuery.data?.total, + }; +}; + +export default useRoundVoteResultQuery; diff --git a/frontend/src/mocks/data/balanceContent.json b/frontend/src/mocks/data/balanceContent.json index 5b1a4952b..8fec9067d 100644 --- a/frontend/src/mocks/data/balanceContent.json +++ b/frontend/src/mocks/data/balanceContent.json @@ -1,4 +1,6 @@ { + "totalRound": 5, + "currentRound": 1, "contentId": 1, "category": "연애", "question": "당신의 결혼 상대는?", diff --git a/frontend/src/mocks/data/finalResult.json b/frontend/src/mocks/data/finalResult.json new file mode 100644 index 000000000..6ff5cc05b --- /dev/null +++ b/frontend/src/mocks/data/finalResult.json @@ -0,0 +1,37 @@ +[ + { + "rank": 1, + "name": "타콩", + "percent": 100 + }, + { + "rank": 2, + "name": "프콩", + "percent": 80 + }, + { + "rank": 3, + "name": "마콩", + "percent": 80 + }, + { + "rank": 4, + "name": "썬콩", + "percent": 60 + }, + { + "rank": 5, + "name": "포콩", + "percent": 40 + }, + { + "rank": 6, + "name": "강콩", + "percent": 20 + }, + { + "rank": 7, + "name": "프콩", + "percent": 0 + } +] diff --git a/frontend/src/mocks/data/roundVoteResult.json b/frontend/src/mocks/data/roundVoteResult.json new file mode 100644 index 000000000..f2804588a --- /dev/null +++ b/frontend/src/mocks/data/roundVoteResult.json @@ -0,0 +1,30 @@ +{ + "group": { + "firstOption": { + "optionId": 1, + "name": "100억 빚 송강", + "members": ["타콩", "프콩", "마콩", "썬콩", "포콩", "미콩", "강콩"], + "memberCount": 7, + "percent": 73 + }, + "secondOption": { + "optionId": 2, + "name": "100억 부자 송강호", + "members": ["커콩", "든콩", "조콩"], + "memberCount": 3, + "percent": 27 + } + }, + "total": { + "firstOption": { + "optionId": 1, + "name": "100억 빚 송강", + "percent": 16 + }, + "secondOption": { + "optionId": 2, + "name": "100억 부자 송강호", + "percent": 84 + } + } +} diff --git a/frontend/src/mocks/data/roundVoteResultInitialValue.json b/frontend/src/mocks/data/roundVoteResultInitialValue.json new file mode 100644 index 000000000..023498092 --- /dev/null +++ b/frontend/src/mocks/data/roundVoteResultInitialValue.json @@ -0,0 +1,30 @@ +{ + "group": { + "firstOption": { + "optionId": 1, + "name": "100억 빚 송강", + "members": [], + "memberCount": 0, + "percent": 50 + }, + "secondOption": { + "optionId": 2, + "name": "100억 부자 송강호", + "members": [], + "memberCount": 0, + "percent": 50 + } + }, + "total": { + "firstOption": { + "optionId": 1, + "name": "100억 빚 송강", + "percent": 50 + }, + "secondOption": { + "optionId": 2, + "name": "100억 부자 송강호", + "percent": 50 + } + } +} diff --git a/frontend/src/mocks/handlers/balanceContentHandler.ts b/frontend/src/mocks/handlers/balanceContentHandler.ts index 53921c9e2..a7ecd3aeb 100644 --- a/frontend/src/mocks/handlers/balanceContentHandler.ts +++ b/frontend/src/mocks/handlers/balanceContentHandler.ts @@ -3,9 +3,10 @@ import { http, HttpResponse } from 'msw'; import BALANCE_CONTENT from '../data/balanceContent.json'; import { MOCK_API_URL } from '@/constants/url'; +import { BalanceContent } from '@/types/balanceContent'; const fetchBalanceContentHandler = () => { - return HttpResponse.json(BALANCE_CONTENT); + return HttpResponse.json(BALANCE_CONTENT); }; export const contentHandler = [http.get(MOCK_API_URL.balanceContent, fetchBalanceContentHandler)]; diff --git a/frontend/src/mocks/handlers/voteHandler.ts b/frontend/src/mocks/handlers/voteHandler.ts index a8dc35b22..788c20a44 100644 --- a/frontend/src/mocks/handlers/voteHandler.ts +++ b/frontend/src/mocks/handlers/voteHandler.ts @@ -1,6 +1,12 @@ import { http, HttpResponse } from 'msw'; +import BALANCE_CONTENT from '../data/balanceContent.json'; +import FINAL_RESULT from '../data/finalResult.json'; +import VOTE_RESULT from '../data/roundVoteResult.json'; + import { MOCK_API_URL } from '@/constants/url'; +import { BalanceContent } from '@/types/balanceContent'; +import { RoundVoteResult } from '@/types/roundVoteResult'; const voteBalanceContentHandler = async ({ request }: { request: Request }) => { const body = await request.json(); @@ -13,4 +19,23 @@ const voteBalanceContentHandler = async ({ request }: { request: Request }) => { ); }; -export const voteHandler = [http.post(MOCK_API_URL.vote, voteBalanceContentHandler)]; +const fetchVoteResultHandler = async () => { + return HttpResponse.json(VOTE_RESULT); +}; + +const goToNextRoundHandler = () => { + BALANCE_CONTENT.currentRound += 1; + + return HttpResponse.json(BALANCE_CONTENT, { status: 201 }); +}; + +const fetchFinalResultHandler = async () => { + return HttpResponse.json(FINAL_RESULT); +}; + +export const voteHandler = [ + http.post(MOCK_API_URL.vote, voteBalanceContentHandler), + http.get(MOCK_API_URL.roundVoteResult, fetchVoteResultHandler), + http.post(MOCK_API_URL.moveNextRound, goToNextRoundHandler), + http.get(MOCK_API_URL.finalResult, fetchFinalResultHandler), +]; diff --git a/frontend/src/pages/MainPage/MainPage.styled.ts b/frontend/src/pages/MainPage/MainPage.styled.ts index b38e06d2c..228496485 100644 --- a/frontend/src/pages/MainPage/MainPage.styled.ts +++ b/frontend/src/pages/MainPage/MainPage.styled.ts @@ -7,10 +7,10 @@ export const mainPageLayout = css` flex-direction: column; justify-content: center; align-items: center; + gap: 3rem; height: 100%; background-color: ${Theme.color.peanut200}; - gap: 3rem; `; export const logoWrapper = css` diff --git a/frontend/src/pages/NicknamePage/NicknamePage.styled.ts b/frontend/src/pages/NicknamePage/NicknamePage.styled.ts index 7f9fd3a51..30879a11d 100644 --- a/frontend/src/pages/NicknamePage/NicknamePage.styled.ts +++ b/frontend/src/pages/NicknamePage/NicknamePage.styled.ts @@ -6,9 +6,9 @@ export const profile = css` width: 8rem; height: 8rem; margin-top: 4rem; + border-radius: 50%; background-color: ${Theme.color.gray300}; - border-radius: 50%; `; export const nickname = css` @@ -25,9 +25,9 @@ export const nicknameInputWrapper = css` width: 26.8rem; height: 4.8rem; padding: 0 1rem; + border-radius: 1rem; background-color: ${Theme.color.gray200}; - border-radius: 1rem; `; export const nicknameInput = css` diff --git a/frontend/src/pages/RoundResultPage/RoundResultPage.styled.ts b/frontend/src/pages/RoundResultPage/RoundResultPage.styled.ts deleted file mode 100644 index 410f844f0..000000000 --- a/frontend/src/pages/RoundResultPage/RoundResultPage.styled.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { css } from '@emotion/react'; - -export const NicknameListWrapper = css` - margin-top: 1.6rem; - margin-bottom: 0.8rem; -`; diff --git a/frontend/src/pages/RoundResultPage/RoundResultPage.tsx b/frontend/src/pages/RoundResultPage/RoundResultPage.tsx index c0647db9b..eef185169 100644 --- a/frontend/src/pages/RoundResultPage/RoundResultPage.tsx +++ b/frontend/src/pages/RoundResultPage/RoundResultPage.tsx @@ -1,28 +1,14 @@ -import { useNavigate } from 'react-router-dom'; - -import { NicknameListWrapper } from './RoundResultPage.styled'; - -import Button from '@/components/common/Button/Button'; +import NextRoundButton from '@/components/common/NextRoundButton/NextRoundButton'; import Content from '@/components/layout/Content/Content'; -import NicknameList from '@/components/NicknameList/NicknameList'; -import RoundVoteResult from '@/components/RoundVoteResult/RoundVoteResult'; +import RoundVoteContainer from '@/components/RoundVoteContainer/RoundVoteContainer'; import TopicContainer from '@/components/TopicContainer/TopicContainer'; const RoundResultPage = () => { - const navigate = useNavigate(); - - const goToGameResult = () => { - navigate('/game-result'); - }; - return ( - -
- -
-