diff --git a/packages/admin/src/constants/message.ts b/packages/admin/src/constants/message.ts index 67d2bc4a..a8390ea0 100644 --- a/packages/admin/src/constants/message.ts +++ b/packages/admin/src/constants/message.ts @@ -1,6 +1,6 @@ export const enum ErrorMessage { INVALID_DURATION = '올바르지 않은 기간입니다.', - INVALID_INPUT = '올바른 정보를 입력해 주세요.', + INVALID_INPUT = '올바른 정보를 입력해 주세요.', NEED_LOGIN = '로그인이 필요한 기능입니다.', } @@ -9,5 +9,5 @@ export const enum ConfirmMessage { } export const enum InfoMessage { - WELCOME = '환영합니다.\n관리자님', + WELCOME = '환영합니다.\n관리자님', } diff --git a/packages/admin/src/layouts/appLayout.tsx b/packages/admin/src/layouts/appLayout.tsx index 36d21542..e90a1f33 100644 --- a/packages/admin/src/layouts/appLayout.tsx +++ b/packages/admin/src/layouts/appLayout.tsx @@ -32,9 +32,7 @@ export default function AppLayout() { return (
- { - accessToken && - } + {accessToken && }
diff --git a/packages/admin/src/pages/events/QuizEventTab.tsx b/packages/admin/src/pages/events/QuizEventTab.tsx index 3b9102b1..f90ab692 100644 --- a/packages/admin/src/pages/events/QuizEventTab.tsx +++ b/packages/admin/src/pages/events/QuizEventTab.tsx @@ -60,9 +60,7 @@ function QuizEventTab() { return (
- {quizEvent?.map((quiz, index) => ( - - ))} + {quizEvent?.map((quiz, index) => )}
); diff --git a/packages/admin/src/pages/review/Review.tsx b/packages/admin/src/pages/review/Review.tsx index c7fff4c9..77e2b110 100644 --- a/packages/admin/src/pages/review/Review.tsx +++ b/packages/admin/src/pages/review/Review.tsx @@ -1,6 +1,4 @@ function Review() { - return
- 리뷰 입니다. -
; + return
리뷰 입니다.
; } export default Review; diff --git a/packages/admin/src/services/api/types/apiType.ts b/packages/admin/src/services/api/types/apiType.ts index 937a4f98..3e6366b7 100644 --- a/packages/admin/src/services/api/types/apiType.ts +++ b/packages/admin/src/services/api/types/apiType.ts @@ -55,62 +55,62 @@ export interface LoginProps { } export interface Payload { - [API.COMMON_EVENT]: { - [METHOD.GET]: Record; - [METHOD.POST]: CommonEvent; - }; - [API.QUIZ_LIST]: { - [METHOD.GET]: Record; - }; - [API.QUIZ]: { - [METHOD.POST]: Quiz; - }; - [API.RACING_WINNERS]: { - [METHOD.GET]: Record; - [METHOD.POST]: WinnerSetting[]; - }; - [API.PERSONALITY_TEST_LIST]: { - [METHOD.GET]: Record; - }; - [API.PERSONALITY_TEST]: { - [METHOD.POST]: PersonalityTest; - }; - [API.LOGIN]: { - [METHOD.POST]: LoginProps; - }; + [API.COMMON_EVENT]: { + [METHOD.GET]: Record; + [METHOD.POST]: CommonEvent; + }; + [API.QUIZ_LIST]: { + [METHOD.GET]: Record; + }; + [API.QUIZ]: { + [METHOD.POST]: Quiz; + }; + [API.RACING_WINNERS]: { + [METHOD.GET]: Record; + [METHOD.POST]: WinnerSetting[]; + }; + [API.PERSONALITY_TEST_LIST]: { + [METHOD.GET]: Record; + }; + [API.PERSONALITY_TEST]: { + [METHOD.POST]: PersonalityTest; + }; + [API.LOGIN]: { + [METHOD.POST]: LoginProps; + }; } export interface Response { - [API.COMMON_EVENT]: { - [METHOD.GET]: CommonEvent; - [METHOD.POST]: Record; - }; - [API.QUIZ_LIST]: { - [METHOD.GET]: Quiz[]; - }; - [API.QUIZ]: { - [METHOD.POST]: Quiz; - }; - [API.RACING_WINNERS]: { - [METHOD.GET]: RacingWinner[]; - [METHOD.POST]: string; - }; - [API.PERSONALITY_TEST_LIST]: { - [METHOD.GET]: PersonalityTest[]; - }; - [API.PERSONALITY_TEST]: { - [METHOD.POST]: PersonalityTest; - }; - [API.LOGIN]: { - [METHOD.POST]: { - accessToken: string; - }; - }; + [API.COMMON_EVENT]: { + [METHOD.GET]: CommonEvent; + [METHOD.POST]: Record; + }; + [API.QUIZ_LIST]: { + [METHOD.GET]: Quiz[]; + }; + [API.QUIZ]: { + [METHOD.POST]: Quiz; + }; + [API.RACING_WINNERS]: { + [METHOD.GET]: RacingWinner[]; + [METHOD.POST]: string; + }; + [API.PERSONALITY_TEST_LIST]: { + [METHOD.GET]: PersonalityTest[]; + }; + [API.PERSONALITY_TEST]: { + [METHOD.POST]: PersonalityTest; + }; + [API.LOGIN]: { + [METHOD.POST]: { + accessToken: string; + }; + }; } export type FetchDataRequestOptions = { - path: K; - payload?: Payload[K][T]; - method: T; // T가 'GET', 'POST', 'PUT', 'DELETE' 중 하나로 제한됩니다. - headers?: HeadersInit; + path: K; + payload?: Payload[K][T]; + method: T; // T가 'GET', 'POST', 'PUT', 'DELETE' 중 하나로 제한됩니다. + headers?: HeadersInit; }; diff --git a/packages/admin/src/utils/fetchData.ts b/packages/admin/src/utils/fetchData.ts index 5a0dba2f..807bb05e 100644 --- a/packages/admin/src/utils/fetchData.ts +++ b/packages/admin/src/utils/fetchData.ts @@ -3,26 +3,22 @@ import { BASE_URL, METHOD } from 'src/constants/api.ts'; import { FetchDataRequestOptions, Payload, Response } from 'src/services/api/types/apiType.ts'; // FetchDataRequestOptions의 제네릭 타입을 수정합니다. - // fetchData 함수의 제네릭 타입을 수정합니다. +// fetchData 함수의 제네릭 타입을 수정합니다. const fetchData = async < - K extends keyof Payload, - T extends keyof Payload[K] & keyof Response[K] & METHOD, - >( - { - path, - payload, - method, - }: FetchDataRequestOptions): Promise => { - const http = new FetchWrapper(BASE_URL); + K extends keyof Payload, + T extends keyof Payload[K] & keyof Response[K] & METHOD, +>({ + path, + payload, + method, +}: FetchDataRequestOptions): Promise => { + const http = new FetchWrapper(BASE_URL); - if (method === METHOD.GET) { - return http.get(path); - } + if (method === METHOD.GET) { + return http.get(path); + } - return http.post( - path, - payload as Payload[K][T], - ); + return http.post(path, payload as Payload[K][T]); }; export default fetchData; diff --git a/packages/common/src/constants/socket.ts b/packages/common/src/constants/socket.ts index e30dc03a..0e5206f8 100644 --- a/packages/common/src/constants/socket.ts +++ b/packages/common/src/constants/socket.ts @@ -3,4 +3,7 @@ export const CHAT_SOCKET_ENDPOINTS = { PUBLISH: '/app/chat.sendMessage', } as const; -export const RACING_SOCKET_ENDPOINTS = {} as const; +export const RACING_SOCKET_ENDPOINTS = { + SUBSCRIBE: '/topic/game', + PUBLISH: '/app/game.sendGameData', +} as const; diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 89de65bb..e4b135cd 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -4,3 +4,4 @@ export { default as FetchWrapper } from './api/index.ts'; export { default as Cookie } from './storage/index.ts'; export { default as Socket } from './socket.ts'; +export type { SocketSubscribeCallbackType } from './socket.ts'; diff --git a/packages/common/src/utils/socket.ts b/packages/common/src/utils/socket.ts index 21037061..c8dfd8a6 100644 --- a/packages/common/src/utils/socket.ts +++ b/packages/common/src/utils/socket.ts @@ -1,6 +1,7 @@ import { Client, IFrame, IMessage, StompSubscription } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; +export type SocketSubscribeCallbackType = (data: unknown, messageId: string) => void; export default class Socket { private client: Client; @@ -25,7 +26,8 @@ export default class Socket { this.client.onConnect = () => callback?.(true); this.client.onStompError = (error) => { - console.error('STOMP Error', error); + alert(`실시간 데이터 연동에 실패했습니다. (${error})`); + console.error(error); callback?.(false); }; @@ -56,12 +58,12 @@ export default class Socket { callback, }: { destination: string; - callback: (messageId: string, message: IMessage) => void; + callback: SocketSubscribeCallbackType; }) { const subscription = this.client.subscribe(destination, (message: IMessage) => { const messageId = message.headers['message-id']; - - callback(messageId, message); + const data = JSON.parse(message.body); + callback(data, messageId); }); this.subscriptions.set(destination, subscription); } @@ -71,7 +73,7 @@ export default class Socket { callback, }: { destination: string; - callback: (messageId: string, message: IMessage) => void; + callback: SocketSubscribeCallbackType; }) { if (this.client.connected) { this.createSubscription({ destination, callback }); diff --git a/packages/user/package.json b/packages/user/package.json index 60840273..885b15d2 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -25,9 +25,11 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "jsdom": "^24.1.1", - "lucide-react": "^0.417.0" + "lucide-react": "^0.417.0", + "numeral": "^2.0.6" }, "devDependencies": { + "@types/numeral": "^2", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", "postcss": "^8.4.39", diff --git a/packages/user/src/App.tsx b/packages/user/src/App.tsx index 2b03676b..a3533923 100644 --- a/packages/user/src/App.tsx +++ b/packages/user/src/App.tsx @@ -3,7 +3,7 @@ import ToasterStack from 'src/components/common/toast/ToasterStack.tsx'; import AppProviders from 'src/libs/index.tsx'; import router from 'src/routes/router.tsx'; import CasperCursor from './components/cursor/CasperCursor.tsx'; -import useCursor from './hooks/useCorusr.ts'; +import useCursor from './hooks/useCursor.ts'; export default function App() { useCursor(); diff --git a/packages/user/src/components/cursor/CasperCursor.tsx b/packages/user/src/components/cursor/CasperCursor.tsx index 6a303f42..2bd138ce 100644 --- a/packages/user/src/components/cursor/CasperCursor.tsx +++ b/packages/user/src/components/cursor/CasperCursor.tsx @@ -1,11 +1,18 @@ function CasperCursor() { - return ( -
- 커서 -
- ); + return ( +
+ 커서 +
+ ); } export default CasperCursor; diff --git a/packages/user/src/components/event/chatting/index.tsx b/packages/user/src/components/event/chatting/index.tsx index bdaa5e59..1a829fd5 100644 --- a/packages/user/src/components/event/chatting/index.tsx +++ b/packages/user/src/components/event/chatting/index.tsx @@ -1,15 +1,11 @@ -import { ChatList, ChatProps } from '@softeer/common/components'; -import { CHAT_SOCKET_ENDPOINTS } from '@softeer/common/constants'; -import { IMessage } from '@stomp/stompjs'; -import { useEffect, useState } from 'react'; -import socketClient from 'src/services/socket.ts'; +import { ChatList } from '@softeer/common/components'; +import { UseChatSocketReturnType } from 'src/hooks/socket/useChatSocket.ts'; import Chat from './Chat.tsx'; import ChatInputArea from './inputArea/index.tsx'; /** 실시간 기대평 섹션 */ -export default function RealTimeChatting() { - const { onSendMessage, messages } = useChatSocket(); +export default function RealTimeChatting({ onSendMessage, messages }: UseChatSocketReturnType) { return (
기대평을 남겨보세요!
@@ -24,39 +20,3 @@ export default function RealTimeChatting() {
); } - -function useChatSocket() { - const [messages, setMessages] = useState([]); - - const handleIncomingMessage = (messageId: string, message: IMessage) => { - const parsedMessage: ChatProps = { id: messageId, ...JSON.parse(message.body) }; - setMessages((prevMessages) => [...prevMessages, parsedMessage]); - }; - - useEffect(() => { - socketClient.connect((isConnected) => { - if (isConnected) { - socketClient.subscribe({ - destination: CHAT_SOCKET_ENDPOINTS.SUBSCRIBE, - callback: handleIncomingMessage, - }); - } - }); - return () => socketClient.disconnect(); - }, [socketClient, handleIncomingMessage]); - - const handleSendMessage = (text: string) => { - const chatMessage = { - sender: 1, - team: 'pet', - content: text, - }; - - socketClient.sendMessages({ - destination: CHAT_SOCKET_ENDPOINTS.PUBLISH, - body: chatMessage, - }); - }; - - return { onSendMessage: handleSendMessage, messages }; -} diff --git a/packages/user/src/components/event/racing/controls/ChargeButtonContent.tsx b/packages/user/src/components/event/racing/controls/ChargeButtonContent.tsx new file mode 100644 index 00000000..4e412f86 --- /dev/null +++ b/packages/user/src/components/event/racing/controls/ChargeButtonContent.tsx @@ -0,0 +1,53 @@ +import { Category } from '@softeer/common/types'; +import numeral from 'numeral'; +import { memo, useMemo } from 'react'; +import { TEAM_DESCRIPTIONS } from 'src/constants/teamDescriptions.ts'; +import type { ChargeButtonData } from './ControlButton.tsx'; + +interface ChargeButtonContentProps extends Omit { + type: Category; +} + +const ChargeButtonContent = memo(({ rank, vote, type }: ChargeButtonContentProps) => { + const { shortTitle, title } = TEAM_DESCRIPTIONS[type]; + const displayTitle = shortTitle ?? title; + const formattedVote = useMemo(() => formatVoteCount(vote), [vote]); + + return ( + <> +

{rank}

+
+

{formattedVote}%

+
{displayTitle}
+
+ + ); +}); + +export default ChargeButtonContent; + +/** Utility Functions */ +function formatVoteCount(count: number): string { + const formatted = numeral(count).format('0,0'); // 기본 포맷팅 + return convertToKoreanUnit(formatted); +} + +function convertToKoreanUnit(formatted: string): string { + const number = parseFloat(formatted.replace(/,/g, '')); + + if (number >= 100000000) { + return `${(number / 100000000).toFixed(2)}억`; + } + if (number >= 10000) { + return `${(number / 10000).toFixed(2)}만`; + } + return formatted; +} + +/** Styles */ +const voteStyles: Record = { + travel: 'text-orange-500', + leisure: 'text-yellow-500', + place: 'text-neutral-200', + pet: 'text-yellow-500', +}; diff --git a/packages/user/src/components/event/racing/controls/ChargeButtonWrapper.tsx b/packages/user/src/components/event/racing/controls/ChargeButtonWrapper.tsx new file mode 100644 index 00000000..70136ba2 --- /dev/null +++ b/packages/user/src/components/event/racing/controls/ChargeButtonWrapper.tsx @@ -0,0 +1,51 @@ +import type { Category } from '@softeer/common/types'; +import { ButtonHTMLAttributes } from 'react'; +import GradientBorderWrapper from 'src/components/common/GradientBorderWrapper.tsx'; + +const imageUrls: Record = { + travel: '/images/racing/side/travel.png', + leisure: '/images/racing/side/leisure.png', + place: '/images/racing/side/place.png', + pet: '/images/racing/side/pet.png', +}; + +interface ChargeButtonProps extends Omit, 'type'> { + type: Category; +} + +export default function ChargeButtonWrapper({ + type, + disabled = false, + children, + ...props +}: ChargeButtonProps) { + const imageUrl = imageUrls[type]; + const styles = getStyles({ type, isActive: !disabled }); + + return ( + + ); +} + +function getStyles({ type, isActive }: { type: Category; isActive: boolean }) { + const bgStyles: Record = { + travel: 'bg-gradient-cards1', + leisure: 'bg-gradient-cards2', + place: 'bg-gradient-cards3', + pet: 'bg-gradient-cards4', + }; + + const imageBaseStyles = 'absolute -bottom-[25px] -right-[18px] z-10 w-[100px] object-contain'; + + return { + image: `${imageBaseStyles} ${isActive ? 'transition-transform duration-300 ease-out group-active:scale-125' : ''}`, + button: 'relative overflow-visible group disabled:opacity-50', + borderWrapper: isActive ? 'group-active:animate-rotate' : '', + innerborderWrapper: `flex h-[84px] w-[240px] gap-7 rounded-[inherit] px-[10px] py-[10px] ${bgStyles[type]}`, + }; +} diff --git a/packages/user/src/components/event/racing/controls/ControlButton.tsx b/packages/user/src/components/event/racing/controls/ControlButton.tsx index 4a1f3a34..44f1b94f 100644 --- a/packages/user/src/components/event/racing/controls/ControlButton.tsx +++ b/packages/user/src/components/event/racing/controls/ControlButton.tsx @@ -1,102 +1,100 @@ import type { Category } from '@softeer/common/types'; -import { useEffect, useRef, useState } from 'react'; -import Lightning from 'src/assets/icons/lighting.svg?react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { useToast } from 'src/hooks/useToast.ts'; -import type { Rank } from 'src/types/rank.d.ts'; +import type { Rank } from 'src/types/racing.d.ts'; +import ChargeButtonContent from './ChargeButtonContent.tsx'; +import ChargeButtonWrapper from './ChargeButtonWrapper.tsx'; +import ControllButtonWrapper from './ControllButtonWrapper.tsx'; import Gauge from './Gauge.tsx'; -import TeamButton from './TeamButton.tsx'; - -interface ControlButtonProps { - type: Category; - rank: Rank; - percentage: number; - onScale: () => void; -} const MAX_CLICK = 10; const MIN_PERCENT = 2; const RESET_SECOND = 10000; - const MAX_CLICK_TOAST_DESCRIPTION = '배터리가 떨어질 때까지 기다려주세요!'; -export default function ControlButton({ - type, - rank, - percentage: originPercentage, - onScale, -}: ControlButtonProps) { - const { progress, clickCount, handleClick } = useGaugeProgress(originPercentage, onScale); - - return ( -
-
- - -
- -
- ); +interface ControlButtonProps { + type: Category; + data: ChargeButtonData; + onCharge: () => void; + onFullyCharged: () => void; } -const styles: Record = { - 1: 'left-[40px] z-40', - 2: 'left-[310px] z-30', - 3: 'left-[580px] z-20', - 4: 'left-[850px] z-10', -}; +export interface ChargeButtonData { + rank: Rank; + vote: number; + percentage: number; +} -function useGaugeProgress(originPercentage: number, onClick: () => void) { +const ControlButton = memo( + ({ onCharge, onFullyCharged, type, data }: ControlButtonProps) => { + const { rank, percentage } = data; + const { progress, clickCount, handleClick } = useGaugeProgress({ + percentage, + onCharge, + onFullyCharged, + }); + + return ( + + + + + + + ); + }, +); + +export default ControlButton; + +/** Custom Hook */ +function useGaugeProgress({ + percentage, + onCharge, + onFullyCharged, +}: { + percentage: number; + onCharge: () => void; + onFullyCharged: () => void; +}) { const { toast } = useToast(); - - const [progress, setProgress] = useState(0); + const [progress, setProgress] = useState(percentage); const [clickCount, setClickCount] = useState(0); - const initPercentageRef = useRef(originPercentage); - - useEffect(() => { - initPercentageRef.current = originPercentage; - }, [originPercentage]); - useEffect(() => { - const resetProgress = requestAnimationFrame(() => setProgress(initPercentageRef.current)); - return () => cancelAnimationFrame(resetProgress); + const updateProgress = useCallback((count: number) => { + const newProgress = calculateProgress(count); + setProgress(newProgress); }, []); + const resetProgress = useCallback(() => { + setClickCount(0); + setProgress(percentage); + }, [percentage]); + useEffect(() => { if (clickCount > 0 && clickCount <= MAX_CLICK) { updateProgress(clickCount); } if (clickCount === MAX_CLICK) { + onFullyCharged(); toast({ description: MAX_CLICK_TOAST_DESCRIPTION }); - const resetTimer = setTimeout(resetToInitProgress, RESET_SECOND); + const resetTimer = setTimeout(resetProgress, RESET_SECOND); return () => clearTimeout(resetTimer); } - }, [clickCount, originPercentage]); + }, [clickCount]); - const handleClick = () => { + const handleClick = useCallback(() => { if (clickCount < MAX_CLICK) { setClickCount((count) => count + 1); - onClick(); + onCharge(); } - }; - - const updateProgress = (count: number) => { - const newProgress = MIN_PERCENT + (100 - MIN_PERCENT) * (count / MAX_CLICK); - setProgress(newProgress); - }; - - const resetToInitProgress = () => { - setClickCount(0); - setProgress(initPercentageRef.current); - }; + }, [clickCount, onCharge]); return { progress, clickCount, handleClick }; } + +/** Utility Function */ +function calculateProgress(count: number): number { + return MIN_PERCENT + (100 - MIN_PERCENT) * (count / MAX_CLICK); +} diff --git a/packages/user/src/components/event/racing/controls/ControllButtonWrapper.tsx b/packages/user/src/components/event/racing/controls/ControllButtonWrapper.tsx new file mode 100644 index 00000000..4030f965 --- /dev/null +++ b/packages/user/src/components/event/racing/controls/ControllButtonWrapper.tsx @@ -0,0 +1,28 @@ +import { PropsWithChildren, useMemo } from 'react'; +import type { Rank } from 'src/types/racing.d.ts'; + +interface ControllButtonWrapperProps { + rank: Rank; +} + +export default function ControllButtonWrapper({ + rank, + children, +}: PropsWithChildren) { + const rankStyle = useMemo(() => styles[rank], [rank]); + + return ( +
+ {children} +
+ ); +} + +const styles: Record = { + 1: 'left-[40px] z-40', + 2: 'left-[310px] z-30', + 3: 'left-[580px] z-20', + 4: 'left-[850px] z-10', +}; diff --git a/packages/user/src/components/event/racing/controls/Gauge.tsx b/packages/user/src/components/event/racing/controls/Gauge.tsx index 453c0e50..48172477 100644 --- a/packages/user/src/components/event/racing/controls/Gauge.tsx +++ b/packages/user/src/components/event/racing/controls/Gauge.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import Lightning from 'src/assets/icons/lighting.svg?react'; interface GaugeProps { percent: number; @@ -12,11 +13,14 @@ export default function Gauge({ percent }: GaugeProps) { }, [percent]); return ( -
-
+
+ +
+
+
); } diff --git a/packages/user/src/components/event/racing/controls/TeamButton.tsx b/packages/user/src/components/event/racing/controls/TeamButton.tsx deleted file mode 100644 index ce64a30a..00000000 --- a/packages/user/src/components/event/racing/controls/TeamButton.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { Category } from '@softeer/common/types'; -import { ButtonHTMLAttributes } from 'react'; -import GradientBorderWrapper from 'src/components/common/GradientBorderWrapper.tsx'; -import { TEAM_DESCRIPTIONS } from 'src/constants/teamDescriptions.ts'; -import type { Rank } from 'src/types/rank.d.ts'; - -const imageUrls: Record = { - travel: '/images/racing/side/travel.png', - leisure: '/images/racing/side/leisure.png', - place: '/images/racing/side/place.png', - pet: '/images/racing/side/pet.png', -}; - -interface ButtonProps extends Omit, 'type'> { - type: Category; - rank: Rank; - percent: number; -} - -export default function TeamButton({ - type, - rank, - percent, - disabled = false, - ...props -}: ButtonProps) { - const { shortTitle, title } = TEAM_DESCRIPTIONS[type]; - const imageUrl = imageUrls[type]; - - const displayTitle = shortTitle ?? title; - const formattedPercent = percent.toFixed(2); - - const styles = getStyles({ type, isActive: !disabled }); - - return ( - - ); -} - -function getStyles({ type, isActive }: { type: Category; isActive: boolean }) { - const { bgStyles, fontStyles } = styles[type]; - const imageBaseStyles = 'absolute -bottom-[25px] -right-[18px] z-10 w-[100px] object-contain'; - - return { - percent: `text-body-3 font-medium ${fontStyles}`, - image: `${imageBaseStyles} ${isActive ? 'transition-transform duration-300 ease-out group-active:scale-125' : ''}`, - button: 'relative overflow-visible group disabled:opacity-50', - borderWrapper: isActive ? 'group-active:animate-rotate' : '', - innerborderWrapper: `flex h-[84px] w-[240px] gap-7 rounded-[inherit] px-[10px] py-[10px] ${bgStyles}`, - }; -} - -const styles: Record = { - travel: { bgStyles: 'bg-gradient-cards1', fontStyles: 'text-orange-500' }, - leisure: { bgStyles: 'bg-gradient-cards2', fontStyles: 'text-yellow-500' }, - place: { bgStyles: 'bg-gradient-cards3', fontStyles: 'text-neutral-200' }, - pet: { bgStyles: 'bg-gradient-cards4', fontStyles: 'text-yellow-500' }, -}; diff --git a/packages/user/src/components/event/racing/controls/index.tsx b/packages/user/src/components/event/racing/controls/index.tsx index 17636061..6dce1df5 100644 --- a/packages/user/src/components/event/racing/controls/index.tsx +++ b/packages/user/src/components/event/racing/controls/index.tsx @@ -1,24 +1,57 @@ import { CATEGORIES } from '@softeer/common/constants'; import { Category } from '@softeer/common/types'; -import type { CategoryRankMap } from 'src/types/rank.d.ts'; +import { useMemo } from 'react'; +import type { UseRacingSocketReturnType } from 'src/hooks/socket/useRacingSocket.ts'; +import type { VoteStatus } from 'src/types/racing.d.ts'; import ControlButton from './ControlButton.tsx'; -interface RacingControlsProps { - ranks: CategoryRankMap; - setScaledType: (type: Category) => void; +interface RacingControlsProps extends Omit { + onCharge: (type: Category) => void; } -export default function RacingControls({ ranks, setScaledType }: RacingControlsProps) { +export default function RacingControls({ + ranks, + votes, + onCharge, + onCarFullyCharged, +}: RacingControlsProps) { + const percentage = useMemo(() => calculatePercentage(votes), [votes]); + return (
{CATEGORIES.map((type) => ( setScaledType(type)} + data={{ + rank: ranks[type], + percentage: percentage[type], + vote: votes[type], + }} + onFullyCharged={() => onCarFullyCharged(type)} + onCharge={() => onCharge(type)} /> ))}
); } + +/** Helper Function */ +function calculatePercentage(voteStatus: VoteStatus): VoteStatus { + const totalVotes = Object.values(voteStatus).reduce((sum, value) => sum + value, 0); + + if (totalVotes === 0) { + return { + pet: 0, + place: 0, + travel: 0, + leisure: 0, + }; + } + + return { + pet: (voteStatus.pet / totalVotes) * 100, + place: (voteStatus.place / totalVotes) * 100, + travel: (voteStatus.travel / totalVotes) * 100, + leisure: (voteStatus.leisure / totalVotes) * 100, + }; +} diff --git a/packages/user/src/components/event/racing/dashboard/Casper.tsx b/packages/user/src/components/event/racing/dashboard/Casper.tsx index 830d97bd..ffab69d2 100644 --- a/packages/user/src/components/event/racing/dashboard/Casper.tsx +++ b/packages/user/src/components/event/racing/dashboard/Casper.tsx @@ -1,7 +1,7 @@ import type { Category } from '@softeer/common/types'; import MarkerIcon from 'src/assets/icons/car-marker.svg?react'; import useAuth from 'src/hooks/useAuth.tsx'; -import type { Rank } from 'src/types/rank.d.ts'; +import type { Rank } from 'src/types/racing.d.ts'; interface CasperProps { type: Category; diff --git a/packages/user/src/components/event/racing/dashboard/RacingTitle.tsx b/packages/user/src/components/event/racing/dashboard/RacingTitle.tsx index 6c82d367..1bd88f23 100644 --- a/packages/user/src/components/event/racing/dashboard/RacingTitle.tsx +++ b/packages/user/src/components/event/racing/dashboard/RacingTitle.tsx @@ -1,11 +1,11 @@ export default function RacingTitle() { return ( <> -

+

버튼을 연타해 승리를 CHARGE하세요! -

+
-

+

1등에 가까워질 수 있도록 배터리를 가득 충전 🔋해주세요!

diff --git a/packages/user/src/components/event/racing/dashboard/index.tsx b/packages/user/src/components/event/racing/dashboard/index.tsx index 16af2a91..d6dcf8c4 100644 --- a/packages/user/src/components/event/racing/dashboard/index.tsx +++ b/packages/user/src/components/event/racing/dashboard/index.tsx @@ -1,24 +1,30 @@ import { CATEGORIES } from '@softeer/common/constants'; import type { Category } from '@softeer/common/types'; import { Suspense } from 'react'; -import type { CategoryRankMap } from 'src/types/rank.d.ts'; +import { UseRacingSocketReturnType } from 'src/hooks/socket/useRacingSocket.ts'; import Background from './Background.tsx'; import RacingCard from './card/index.tsx'; import Casper from './Casper.tsx'; import RacingTitle from './RacingTitle.tsx'; import EventTimer from './timer/index.tsx'; -interface RacingDashboardProps { - ranks: CategoryRankMap; - scaledType: Category | null; +interface RacingDashboardProps extends Pick { + chargedCar: Category | null; } -export default function RacingDashboard({ ranks, scaledType }: RacingDashboardProps) { +export default function RacingDashboard({ ranks, chargedCar }: RacingDashboardProps) { return (
- + {CATEGORIES.map((type) => ( + + ))}
); @@ -42,18 +48,3 @@ function RacingCardSection() {
); } - -function CaspersSection({ ranks, scaledType }: RacingDashboardProps) { - return ( - <> - {CATEGORIES.map((type) => ( - - ))} - - ); -} diff --git a/packages/user/src/components/event/racing/index.tsx b/packages/user/src/components/event/racing/index.tsx index 8c9c51b7..8bab6c84 100644 --- a/packages/user/src/components/event/racing/index.tsx +++ b/packages/user/src/components/event/racing/index.tsx @@ -1,28 +1,28 @@ -import type { Category } from '@softeer/common/types'; +import { Category } from '@softeer/common/types'; import { useState } from 'react'; import SECTION_ID from 'src/constants/sectionId.ts'; -import type { CategoryRankMap } from 'src/types/rank.d.ts'; +import { UseRacingSocketReturnType } from 'src/hooks/socket/useRacingSocket.ts'; import RacingControls from './controls/index.tsx'; import RacingDashboard from './dashboard/index.tsx'; /** 실시간 레이싱 섹션 */ -export default function RealTimeRacing() { - const [scaledType, setScaledType] = useState(null); +export default function RealTimeRacing(racingSocket: UseRacingSocketReturnType) { + const [chargedCar, setChargedCar] = useState(null); - const ranks: CategoryRankMap = { - pet: 1, - place: 2, - leisure: 4, - travel: 3, - }; + const { ranks, votes, onCarFullyCharged } = racingSocket; return (
- - + +
); } diff --git a/packages/user/src/components/shared/modal/TeamDescriptionModal.tsx b/packages/user/src/components/shared/modal/TeamDescriptionModal.tsx index eb09ff04..66585bb4 100644 --- a/packages/user/src/components/shared/modal/TeamDescriptionModal.tsx +++ b/packages/user/src/components/shared/modal/TeamDescriptionModal.tsx @@ -14,7 +14,7 @@ export default function TeamDescriptionModal({ type, openTrigger }: TeamDescript return (
-
+
{subTitle} diff --git a/packages/user/src/constants/storageKey.ts b/packages/user/src/constants/storageKey.ts index 63a598f5..7c0f207e 100644 --- a/packages/user/src/constants/storageKey.ts +++ b/packages/user/src/constants/storageKey.ts @@ -3,6 +3,7 @@ const STORAGE_KEY_PREFIX = 'hyundai-softeer-team4'; const STORAGE_KEYS = { USER: `${STORAGE_KEY_PREFIX}-user`, TOKEN: `${STORAGE_KEY_PREFIX}-token`, + RANK: `${STORAGE_KEY_PREFIX}-rank`, } as const; export default STORAGE_KEYS; diff --git a/packages/user/src/constants/teamDescriptions.ts b/packages/user/src/constants/teamDescriptions.ts index 72e51ef1..04582f1c 100644 --- a/packages/user/src/constants/teamDescriptions.ts +++ b/packages/user/src/constants/teamDescriptions.ts @@ -29,4 +29,4 @@ export const TEAM_DESCRIPTIONS: Record = { details: '오프로드의 정석을 느끼게 해줄 다양한 액세서리를 캐스퍼에 적용해보세요. 자전거와 함께할 수 있도록 돕는 루프 자전거 캐리어, 험한 길도 끄떡없는 스틸 언더커버를 제공합니다.', }, -} as const; \ No newline at end of file +} as const; diff --git a/packages/user/src/hooks/socket/index.ts b/packages/user/src/hooks/socket/index.ts new file mode 100644 index 00000000..d161084a --- /dev/null +++ b/packages/user/src/hooks/socket/index.ts @@ -0,0 +1,35 @@ +import { CHAT_SOCKET_ENDPOINTS, RACING_SOCKET_ENDPOINTS } from '@softeer/common/constants'; +import { useEffect } from 'react'; +import socketClient from 'src/services/socket.ts'; +import useChatSocket, { UseChatSocketReturnType } from './useChatSocket.ts'; +import useRacingSocket, { UseRacingSocketReturnType } from './useRacingSocket.ts'; + +export type UseSocketType = { + chatSocket: UseChatSocketReturnType; + racingSocket: UseRacingSocketReturnType; +}; +export default function useSocket() { + const chatSocket = useChatSocket(); + const { onReceiveMessage } = chatSocket; + + const racingSocket = useRacingSocket(); + const { onReceiveStatus } = racingSocket; + + useEffect(() => { + socketClient.connect((isConnected) => { + if (isConnected) { + socketClient.subscribe({ + destination: CHAT_SOCKET_ENDPOINTS.SUBSCRIBE, + callback: onReceiveMessage, + }); + socketClient.subscribe({ + destination: RACING_SOCKET_ENDPOINTS.SUBSCRIBE, + callback: onReceiveStatus, + }); + } + }); + return () => socketClient.disconnect(); + }, [socketClient]); + + return { chatSocket, racingSocket }; +} diff --git a/packages/user/src/hooks/socket/useChatSocket.ts b/packages/user/src/hooks/socket/useChatSocket.ts new file mode 100644 index 00000000..f1a5c821 --- /dev/null +++ b/packages/user/src/hooks/socket/useChatSocket.ts @@ -0,0 +1,39 @@ +import { ChatProps } from '@softeer/common/components'; +import { CHAT_SOCKET_ENDPOINTS } from '@softeer/common/constants'; +import { SocketSubscribeCallbackType } from '@softeer/common/utils'; +import { useState } from 'react'; +import useAuth from 'src/hooks/useAuth.tsx'; +import socketClient from 'src/services/socket.ts'; +import type { User } from 'src/types/user.d.ts'; + +export type UseChatSocketReturnType = ReturnType; + +export default function useChatSocket() { + const { user } = useAuth(); + + const [chatMessages, setChatMessages] = useState([]); + + const handleIncomingMessage: SocketSubscribeCallbackType = (data: unknown, messageId: string) => { + const parsedData = data as Omit; + const parsedMessage = { id: messageId, ...parsedData }; + setChatMessages((prevMessages) => [...prevMessages, parsedMessage] as ChatProps[]); + }; + + const handleSendMessage = (content: string) => { + console.assert(user !== null, '로그인 되지 않은 사용자가 메세지 전송을 시도했습니다.'); + + const { id: sender, type: team } = user as NonNullable; + const chatMessage = { sender, team, content }; + + socketClient.sendMessages({ + destination: CHAT_SOCKET_ENDPOINTS.PUBLISH, + body: chatMessage, + }); + }; + + return { + onReceiveMessage: handleIncomingMessage, + onSendMessage: handleSendMessage, + messages: chatMessages, + }; +} diff --git a/packages/user/src/hooks/socket/useRacingSocket.ts b/packages/user/src/hooks/socket/useRacingSocket.ts new file mode 100644 index 00000000..a25da756 --- /dev/null +++ b/packages/user/src/hooks/socket/useRacingSocket.ts @@ -0,0 +1,111 @@ +import { RACING_SOCKET_ENDPOINTS } from '@softeer/common/constants'; +import { Category } from '@softeer/common/types'; +import type { SocketSubscribeCallbackType } from '@softeer/common/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import useRacingRankStorage from 'src/hooks/storage/useRacingRankStorage.ts'; +import socketClient from 'src/services/socket.ts'; +import type { + Rank, + RankStatus, + SocketCategory, + SocketData, + VoteStatus, +} from 'src/types/racing.d.ts'; + +/** + * Mapping between Category and SocketCategory + */ +const categoryToSocketCategory: Record = { + pet: 'P', + travel: 'T', + place: 'S', + leisure: 'L', +}; + +const socketCategoryToCategory: Record = { + P: 'pet', + T: 'travel', + S: 'place', + L: 'leisure', +}; + +export type UseRacingSocketReturnType = ReturnType; + +export default function useRacingSocket() { + const [storedRank, storeRank] = useRacingRankStorage(); + const [ranks, setRanks] = useState(storedRank); + const [votes, setVotes] = useState({ + pet: 0, + place: 0, + travel: 0, + leisure: 0, + }); + + const newRankStatus = useMemo(() => calculateRank(votes), [votes]); + + useEffect(() => { + if (hasRankChanged(newRankStatus, ranks)) { + setRanks(newRankStatus); + storeRank(newRankStatus); + } + }, [newRankStatus, ranks, storeRank]); + + const handleStatusChange: SocketSubscribeCallbackType = useCallback((data: unknown) => { + const newVoteStatus = parseSocketVoteData(data as SocketData); + setVotes(newVoteStatus); + }, []); + + const handleCarFullyCharged = (category: Category) => { + const chargeData = { [categoryToSocketCategory[category]]: 1 }; + + const completeChargeData = Object.keys(categoryToSocketCategory).reduce((acc, key) => { + const socketCategory = categoryToSocketCategory[key as Category]; + acc[socketCategory] = chargeData[socketCategory] ?? 0; + return acc; + }, {} as Record); + + socketClient.sendMessages({ + destination: RACING_SOCKET_ENDPOINTS.PUBLISH, + body: completeChargeData, + }); + }; + + return { + votes, + ranks, + onReceiveStatus: handleStatusChange, + onCarFullyCharged: handleCarFullyCharged, + }; +} + +/** + * Helper Functions + */ + +function calculateRank(vote: VoteStatus): RankStatus { + const sortedCategories = (Object.keys(vote) as Category[]).sort( + (a, b) => Number(vote[b]) - Number(vote[a]), + ); + + return sortedCategories.reduce( + (rankStatus, category, index) => ({ + ...rankStatus, + [category]: (index + 1) as Rank, + }), + {} as RankStatus, + ); +} + +function hasRankChanged(newRank: RankStatus, currentRank: RankStatus): boolean { + return Object.keys(newRank).some( + (category) => newRank[category as Category] !== currentRank[category as Category], + ); +} + +function parseSocketVoteData(data: SocketData): VoteStatus { + return Object.entries(data).reduce((acc, [socketCategory, value]) => { + const category = socketCategoryToCategory[socketCategory as SocketCategory]; + acc[category] = value; + return acc; + }, {} as VoteStatus); +} diff --git a/packages/user/src/hooks/storage/useRacingRankStorage.ts b/packages/user/src/hooks/storage/useRacingRankStorage.ts new file mode 100644 index 00000000..f37db728 --- /dev/null +++ b/packages/user/src/hooks/storage/useRacingRankStorage.ts @@ -0,0 +1,14 @@ +import STORAGE_KEYS from 'src/constants/storageKey.ts'; +import useStorage from 'src/hooks/storage/index.ts'; +import type { RankStatus } from 'src/types/racing.d.ts'; + +const INIT_RANK: RankStatus = { + pet: 1, + place: 2, + travel: 3, + leisure: 4, +}; + +const useRacingRankStorage = () => useStorage(STORAGE_KEYS.RANK, INIT_RANK); + +export default useRacingRankStorage; diff --git a/packages/user/src/hooks/useCorusr.ts b/packages/user/src/hooks/useCorusr.ts deleted file mode 100644 index 0d63374a..00000000 --- a/packages/user/src/hooks/useCorusr.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from 'react'; - -function useCursor() { - useEffect(() => { - const cursorRounded = document.querySelector('.custom-cursor-obj') as HTMLElement; - if (!cursorRounded) { - console.error('커서 요소가 페이지에서 발견되지 않았습니다.'); - return; - } - const moveCursor = (e: MouseEvent) => { - const mouseY = e.clientY; - const mouseX = e.clientX; - cursorRounded.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`; - }; - window.addEventListener('mousemove', moveCursor); - return () => { - window.removeEventListener('mousemove', moveCursor); - }; - }, []); -} - -export default useCursor; diff --git a/packages/user/src/hooks/useCursor.ts b/packages/user/src/hooks/useCursor.ts new file mode 100644 index 00000000..83df66db --- /dev/null +++ b/packages/user/src/hooks/useCursor.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +function useCursor() { + useEffect(() => { + const cursorRounded = document.querySelector('.custom-cursor-obj') as HTMLElement; + if (!cursorRounded) { + console.error('커서 요소가 페이지에서 발견되지 않았습니다.'); + return; + } + const moveCursor = (e: MouseEvent) => { + const mouseY = e.clientY; + const mouseX = e.clientX; + cursorRounded.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`; + }; + window.addEventListener('mousemove', moveCursor); + return () => { + window.removeEventListener('mousemove', moveCursor); + }; + }, []); +} + +export default useCursor; diff --git a/packages/user/src/pages/EventPage.tsx b/packages/user/src/pages/EventPage.tsx index 6c994d4f..cd79b3d8 100644 --- a/packages/user/src/pages/EventPage.tsx +++ b/packages/user/src/pages/EventPage.tsx @@ -1,17 +1,20 @@ import { useEffect } from 'react'; import { RealTimeChatting, RealTimeRacing } from 'src/components/event/index.ts'; import SECTION_ID from 'src/constants/sectionId.ts'; +import useSocket from 'src/hooks/socket/index.ts'; import scrollToElementId from 'src/utils/scrollToElementId.ts'; export default function EventPage() { + const { chatSocket, racingSocket } = useSocket(); + useEffect(() => { scrollToElementId({ sectionId: SECTION_ID.RACING, behavior: 'instant' }); }, []); return ( <> - - + + ); } diff --git a/packages/user/src/types/racing.d.ts b/packages/user/src/types/racing.d.ts new file mode 100644 index 00000000..e34c5a17 --- /dev/null +++ b/packages/user/src/types/racing.d.ts @@ -0,0 +1,10 @@ +import type { Category } from '@softeer/common/types'; + +const ranks = [1, 2, 3, 4] as const; +export type Rank = (typeof ranks)[number]; + +type SocketCategory = 'P' | 'T' | 'S' | 'L'; + +export type SocketData = Record; +export type VoteStatus = Record; +export type RankStatus = Record; diff --git a/packages/user/src/types/rank.d.ts b/packages/user/src/types/rank.d.ts deleted file mode 100644 index b207f865..00000000 --- a/packages/user/src/types/rank.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Category } from '@softeer/common/types'; - -const ranks = [1, 2, 3, 4] as const; -export type Rank = (typeof ranks)[number]; - -export type CategoryRankMap = Record; diff --git a/packages/user/src/utils/getSharedLink.ts b/packages/user/src/utils/getSharedLink.ts index 6ab61ab2..c249fb1a 100644 --- a/packages/user/src/utils/getSharedLink.ts +++ b/packages/user/src/utils/getSharedLink.ts @@ -1,5 +1,5 @@ import { Category } from '@softeer/common/types'; -import { User } from 'src/types/user.js'; +import type { User } from 'src/types/user.d.ts'; const DOMAIN: Record = { default: 'https://www.batro.org', diff --git a/yarn.lock b/yarn.lock index 55c278bc..f8ced6a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1766,6 +1766,13 @@ __metadata: languageName: node linkType: hard +"@types/numeral@npm:^2": + version: 2.0.5 + resolution: "@types/numeral@npm:2.0.5" + checksum: 10c0/b18766cc97e79b5c59130ce1d5d5ad8b9287e1efd5ecac402e8a64e45c50aea8c8940c9974358983036d1abbed365a08f7f4d11b8af16874a5d4d0edce9aa4d4 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.12 resolution: "@types/prop-types@npm:15.7.12" @@ -5379,6 +5386,13 @@ __metadata: languageName: node linkType: hard +"numeral@npm:^2.0.6": + version: 2.0.6 + resolution: "numeral@npm:2.0.6" + checksum: 10c0/5ed008d3fae05cfa4986b77a85ca10bff29ae6e1fa41a04cce05ea21f08a8a104226f88868930e2a94e3239708d6985d111b5d1291e8b9a3049ffc5365c332d4 + languageName: node + linkType: hard + "nwsapi@npm:^2.2.12": version: 2.2.12 resolution: "nwsapi@npm:2.2.12" @@ -7439,12 +7453,14 @@ __metadata: "@radix-ui/react-visually-hidden": "npm:^1.1.0" "@softeer/common": "npm:*" "@tanstack/react-query": "npm:^5.51.11" + "@types/numeral": "npm:^2" "@vitejs/plugin-react": "npm:^4.3.1" autoprefixer: "npm:^10.4.19" class-variance-authority: "npm:^0.7.0" clsx: "npm:^2.1.1" jsdom: "npm:^24.1.1" lucide-react: "npm:^0.417.0" + numeral: "npm:^2.0.6" postcss: "npm:^8.4.39" rollup-plugin-visualizer: "npm:^5.12.0" tailwindcss: "npm:^3.4.6"