Skip to content

Commit 610752e

Browse files
committed
refactor(betting): 재사용 훅 및 프레젠테이셔널 컴포넌트로 분리
데이터·로직을 커스텀 훅으로 추출 useBettingRoomInfo: 방 데이터 페칭 및 파싱 useBettingSocket: 소켓 초기화 및 이벤트(joinRoom, finished, cancelWaitingRoom) 관리 useVotingTimer: 투표 타이머 계산 및 timeover 처리 useBettingInput: 입력 상태 관리 및 유효성 검사(숫자, 길이, 잔액) usePlaceBet: placeBetting 호출 로직 캡슐화 useSharedLink: 공유 링크 생성 및 복사 로직 UI를 역할에 맞게 컴포넌트로 분할 BettingPage(컨테이너) BettingTimer, BettingContainer, BettingInput, BettingSharedLink PredictDetailHeader, PersonalResult, FooterSelector 관심사 분리: UI ↔ 데이터 페칭 ↔ 소켓 ↔ 타이머 ↔ 입력 ↔ 복사 매직 값·클래스 상수화 (퍼센트 스케일, Tailwind 클래스, 라우트 경로) useCallback, useMemo, React.memo 적용으로 성능·가독성 개선 Zod 스키마 및 명시적 TS 타입으로 타입 안전성 강화 도메인별 hooks/components/pages 폴더 구조 재구성
1 parent d0ef4b9 commit 610752e

File tree

114 files changed

+1769
-1630
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

114 files changed

+1769
-1630
lines changed

frontend/src/app/provider/UserProvider.tsx

Lines changed: 0 additions & 95 deletions
This file was deleted.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useState, useEffect, useCallback } from "react";
2+
import { useSessionStorage } from "@/shared/hooks/useSessionStorage";
3+
import { authenticatedUserInfoSchema } from "@betting-duck/shared";
4+
import type { UserInfoWithRoomId } from "../index";
5+
6+
const DEFAULT_USER_INFO: UserInfoWithRoomId = {
7+
message: "OK",
8+
role: "guest",
9+
nickname: "",
10+
duck: 0,
11+
realDuck: 0,
12+
roomId: undefined,
13+
isAuthenticated: false,
14+
};
15+
16+
export function useSessionStoredUser(): [
17+
UserInfoWithRoomId,
18+
(info: Partial<UserInfoWithRoomId>) => Promise<void>,
19+
] {
20+
const { getSessionItem, setSessionItem } = useSessionStorage();
21+
const [userInfo, setUserInfo] =
22+
useState<UserInfoWithRoomId>(DEFAULT_USER_INFO);
23+
24+
useEffect(() => {
25+
let mounted = true;
26+
27+
async function loadUserInfo() {
28+
try {
29+
const raw = await getSessionItem("userInfo");
30+
if (raw) {
31+
const stored = JSON.parse(raw);
32+
const parsed = authenticatedUserInfoSchema.safeParse(stored);
33+
if (!parsed.success) {
34+
console.error(
35+
"세션에 저장된 사용자 정보가 유효하지 않습니다.",
36+
parsed.error,
37+
);
38+
return;
39+
}
40+
if (mounted)
41+
setUserInfo({
42+
...DEFAULT_USER_INFO,
43+
...parsed.data.userInfo,
44+
isAuthenticated: parsed.data.isAuthenticated,
45+
});
46+
}
47+
} catch (error) {
48+
console.error("세션에서 사용자 정보 로드 실패", error);
49+
}
50+
}
51+
52+
loadUserInfo();
53+
return () => {
54+
mounted = false;
55+
};
56+
}, [getSessionItem]);
57+
58+
const updateUserInfo = useCallback(
59+
async (patch: Partial<UserInfoWithRoomId>) => {
60+
const next = { ...userInfo, ...patch };
61+
try {
62+
await setSessionItem("userInfo", JSON.stringify(next));
63+
setUserInfo(next);
64+
} catch {
65+
console.error("세션에 사용자 정보 저장 실패");
66+
}
67+
},
68+
[setSessionItem, userInfo],
69+
);
70+
71+
return [userInfo, updateUserInfo];
72+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from "react";
2+
import type { AuthenticateUserInfo, UserInfo } from "@betting-duck/shared";
3+
import { useSessionStoredUser } from "./hooks/useStoredUser";
4+
5+
export type UserInfoWithRoomId = {
6+
roomId?: string | undefined;
7+
message?: UserInfo["message"];
8+
role?: UserInfo["role"];
9+
nickname?: UserInfo["nickname"];
10+
duck?: UserInfo["duck"];
11+
realDuck?: UserInfo["realDuck"];
12+
isAuthenticated?: AuthenticateUserInfo["isAuthenticated"];
13+
};
14+
15+
interface UserContextType {
16+
userInfo: UserInfoWithRoomId;
17+
setUserInfo: (info: UserInfoWithRoomId) => Promise<void>;
18+
}
19+
20+
const UserContext = React.createContext<UserContextType | null>(null);
21+
22+
function UserProvider({ children }: { children: React.ReactNode }) {
23+
const [userInfo, updateUserInfo] = useSessionStoredUser();
24+
const value = React.useMemo(
25+
() => ({
26+
userInfo,
27+
setUserInfo: updateUserInfo,
28+
}),
29+
[userInfo, updateUserInfo],
30+
);
31+
32+
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
33+
}
34+
35+
export { UserProvider, UserContext };

frontend/src/features/betting-page-admin/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import { BettingSharedLink } from "@/shared/components/BettingSharedLink/Betting
1111
import { PercentageDisplay } from "@/shared/components/PercentageDisplay/PercentageDisplay";
1212
import { endBetRoom, refund } from "./model/api";
1313
import { useLayoutShift } from "@/shared/hooks/useLayoutShift";
14-
import { bettingRoomSchema } from "../betting-page/model/schema";
1514
import { DuckCoinIcon } from "@/shared/icons";
1615
import BettingDetails from "./ui/BettingDetails";
16+
import { bettingProgressInfoSchema } from "@betting-duck/shared";
17+
import { useBettingRoomInfo } from "@/shared/hooks/useBettingRoomInfo";
1718

1819
function BettingPageAdmin() {
1920
useLayoutShift();
@@ -43,6 +44,7 @@ function BettingPageAdmin() {
4344
options: { option1, option2 },
4445
settings: { defaultBetAmount, duration: timer },
4546
} = channel;
47+
const { data: bettingRoomInfo } = useBettingRoomInfo(roomId);
4648

4749
const socket = useSocketIO({
4850
url: "/api/betting",
@@ -115,7 +117,7 @@ function BettingPageAdmin() {
115117
if (!socket) return;
116118

117119
socket.on("fetchBetRoomInfo", (data) => {
118-
const result = bettingRoomSchema.safeParse(data);
120+
const result = bettingProgressInfoSchema.safeParse(data);
119121
if (!result.success) {
120122
console.error(result.error.errors);
121123
return;
@@ -266,7 +268,7 @@ function BettingPageAdmin() {
266268
return (
267269
<div className="bg-layout-main flex h-full w-full flex-col justify-between">
268270
<div className="flex flex-col gap-5">
269-
<BettingTimer socket={socket} bettingRoomInfo={roomInfo} />
271+
<BettingTimer socket={socket} bettingRoomInfo={bettingRoomInfo} />
270272
<div className="flex flex-col gap-6 p-5">
271273
<BettingDetails
272274
title={title}

frontend/src/features/betting-page/api/getUserInfo.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

frontend/src/features/betting-page/hook/useBettingRoomConnection.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import React from "react";
22
import { z } from "zod";
3-
import {
4-
betResultResponseSchema,
5-
responseBetRoomInfo,
6-
} from "@betting-duck/shared";
3+
import { betResultDataSchema, responseBetRoomInfo } from "@betting-duck/shared";
74
import { useSocketIO } from "@/shared/hooks/useSocketIo";
85
import { useNavigate } from "@tanstack/react-router";
96

@@ -72,8 +69,9 @@ function useBettingConnection(
7269
if (!bettingResultResponse.ok) {
7370
throw new Error("배팅 결과를 가져오는데 실패했습니다.");
7471
}
72+
7573
const data = await bettingResultResponse.json();
76-
const bettingResult = betResultResponseSchema.safeParse(data);
74+
const bettingResult = betResultDataSchema.safeParse(data);
7775
if (!bettingResult.success) {
7876
return navigate({
7977
to: "/my-page",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useSocketIO } from "@/shared/hooks/useSocketIo";
2+
import { useNavigate, UseNavigateResult } from "@tanstack/react-router";
3+
import { useEffect, useRef } from "react";
4+
import { ChannelType } from "@betting-duck/shared";
5+
6+
interface SocketConnectProps {
7+
socket: ReturnType<typeof useSocketIO>;
8+
channel: {
9+
id: string;
10+
status: string;
11+
};
12+
joinRef: React.MutableRefObject<boolean>;
13+
fetchRef: React.MutableRefObject<boolean>;
14+
}
15+
16+
function onSocketConnect({
17+
socket,
18+
channel,
19+
joinRef,
20+
fetchRef,
21+
}: SocketConnectProps) {
22+
console.log("베팅 페이지에 소켓에 연결이 되었습니다.");
23+
if (!joinRef.current) {
24+
joinRef.current = true;
25+
socket.emit("joinRoom", {
26+
channel: {
27+
roomId: channel.id,
28+
},
29+
});
30+
}
31+
if (channel.status === "active" && !fetchRef.current) {
32+
fetchRef.current = true;
33+
socket.emit("fetchBetRoomInfo", {
34+
roomId: channel.id,
35+
});
36+
}
37+
}
38+
39+
function onFinshed(channel: ChannelType, navigate: UseNavigateResult<string>) {
40+
console.log("베팅이 종료되었습니다");
41+
navigate({
42+
to: "/betting/$roomId/vote/resultDetail",
43+
params: { roomId: channel.id },
44+
});
45+
}
46+
47+
function onCancelWaitingRoom(
48+
channel: ChannelType,
49+
navigate: UseNavigateResult<string>,
50+
) {
51+
console.log("베팅이 취소되었습니다");
52+
navigate({
53+
to: "/betting/$roomId/vote/resultDetail",
54+
params: { roomId: channel.id },
55+
});
56+
}
57+
58+
export function useBettingSocket(channel: ChannelType) {
59+
const joinRef = useRef(false);
60+
const fetchRef = useRef(false);
61+
const navigate = useNavigate();
62+
63+
const socket = useSocketIO({
64+
url: "/api/betting",
65+
onConnect: () => onSocketConnect({ socket, channel, joinRef, fetchRef }),
66+
onDisconnect: (reason) => {
67+
console.log("베팅 페이지에 소켓 연결이 끊겼습니다.", reason);
68+
joinRef.current = false;
69+
fetchRef.current = false;
70+
},
71+
onError: (error) => {
72+
console.error("베팅 페이지에 소켓 에러가 발생했습니다.", error);
73+
},
74+
});
75+
76+
useEffect(() => {
77+
socket.on("finished", () => onFinshed(channel, navigate));
78+
socket.on("cancelWaitingRoom", () =>
79+
onCancelWaitingRoom(channel, navigate),
80+
);
81+
82+
return () => {
83+
socket.off("finished");
84+
socket.off("cancelWaitingRoom");
85+
};
86+
}, [socket, channel, navigate]);
87+
88+
return socket;
89+
}

0 commit comments

Comments
 (0)