├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── backend-deploy.yml │ ├── backend-test.yml │ ├── frontend-deploy.yml │ └── frontend-test.yml ├── .gitignore ├── .husky ├── pre-commit ├── pre-push └── prepare-commit-msg ├── .npmrc ├── .nvmrc ├── .prettierrc ├── README.md ├── backend ├── .eslintrc.js ├── .gitignore ├── README.md ├── jest.config.ts ├── nest-cli.json ├── package.json ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── dto │ │ │ ├── guest-sign-in-user.dto.ts │ │ │ ├── sign-in-user.dto.ts │ │ │ ├── sign-up-user.dto.ts │ │ │ └── upgrade-guest.dto.ts │ │ ├── user-credential.dto.ts │ │ ├── user.controller.ts │ │ ├── user.entity.ts │ │ ├── user.module.ts │ │ ├── user.repository.ts │ │ └── user.service.ts │ ├── bet-result │ │ ├── bet-result.controller.ts │ │ ├── bet-result.entity.ts │ │ ├── bet-result.module.ts │ │ ├── bet-result.repository.ts │ │ └── bet-result.service.ts │ ├── bet-room │ │ ├── bet-room.controller.ts │ │ ├── bet-room.entity.ts │ │ ├── bet-room.module.ts │ │ ├── bet-room.refund.service.ts │ │ ├── bet-room.repository.ts │ │ ├── bet-room.service.spec.ts │ │ ├── bet-room.service.ts │ │ └── dto │ │ │ ├── create-bet-room.dto.ts │ │ │ └── update-bet-room.dto.ts │ ├── bet │ │ ├── bet.controller.ts │ │ ├── bet.entity.ts │ │ ├── bet.gateway.ts │ │ ├── bet.module.ts │ │ ├── bet.repository.ts │ │ ├── bet.service.ts │ │ └── dto │ │ │ └── placeBetDto.ts │ ├── chat │ │ ├── chat.gateway.ts │ │ └── chat.module.ts │ ├── config │ │ ├── redis.config.ts │ │ ├── swagger.config.ts │ │ └── typeorm.config.ts │ ├── main.ts │ └── utils │ │ ├── db.manager.module.ts │ │ ├── db.manager.ts │ │ ├── filters │ │ ├── global-http-exception.filter.ts │ │ └── global-ws-exception.filter.ts │ │ ├── guards │ │ ├── http-guest-authenticated.guard.ts │ │ ├── http-user-authenticated.guard.ts │ │ └── ws-authenticated.guard.ts │ │ ├── jwt.utils.ts │ │ ├── middlewares │ │ └── logger.middleware.ts │ │ ├── redis-io-adapter.ts │ │ ├── redis-manager.module.ts │ │ ├── redis-timeout-scheduler.lua │ │ ├── redis.manager.ts │ │ └── test │ │ ├── postgres-data.sql │ │ └── redis-data.sh ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── docker-compose-prod.yml ├── docker-compose.yml ├── eslint.config.mjs ├── frontend ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ └── mockServiceWorker.js ├── src │ ├── app │ │ └── provider │ │ │ ├── LayoutProvider.tsx │ │ │ └── UserProvider.tsx │ ├── assets.d.ts │ ├── assets │ │ ├── fonts │ │ │ ├── NanumSquareRound.woff2 │ │ │ ├── NanumSquareRoundB.woff2 │ │ │ └── NanumSquareRoundEB.woff2 │ │ ├── icons │ │ │ ├── arrow-down.svg │ │ │ ├── arrow-up.svg │ │ │ ├── at-sign.svg │ │ │ ├── betting_coin.svg │ │ │ ├── chat.svg │ │ │ ├── create-bet.svg │ │ │ ├── duck.svg │ │ │ ├── header_logo_duck.svg │ │ │ ├── login-id-icon.svg │ │ │ ├── login-password-icon.svg │ │ │ ├── login.svg │ │ │ ├── logout.svg │ │ │ ├── main-logo.svg │ │ │ ├── text.svg │ │ │ └── timer.svg │ │ ├── images │ │ │ ├── emoticon.png │ │ │ ├── main-logo.png │ │ │ ├── pond.png │ │ │ ├── user-plus.png │ │ │ ├── vote-page.png │ │ │ ├── waiting-user.png │ │ │ ├── win_duckicon.png │ │ │ ├── win_peoples.png │ │ │ └── win_trophy.png │ │ └── models │ │ │ ├── betting-duck.glb │ │ │ └── industrial_sunset_puresky_4k.hdr │ ├── env.d.ts │ ├── features │ │ ├── betting-page-admin │ │ │ ├── EndPredictButton.tsx │ │ │ ├── index.tsx │ │ │ └── model │ │ │ │ ├── api.ts │ │ │ │ └── types.ts │ │ ├── betting-page │ │ │ ├── api │ │ │ │ ├── endBetroom.ts │ │ │ │ ├── getBettingRoomInfo.ts │ │ │ │ └── getUserInfo.ts │ │ │ ├── hook │ │ │ │ ├── useBettingContext.ts │ │ │ │ └── useBettingRoomConnection.ts │ │ │ ├── index.tsx │ │ │ ├── model │ │ │ │ ├── schema.ts │ │ │ │ └── var.ts │ │ │ ├── provider │ │ │ │ └── BettingProvider.tsx │ │ │ ├── ui │ │ │ │ ├── BettingContainer.tsx │ │ │ │ ├── BettingFooter.tsx │ │ │ │ ├── BettingHeader.tsx │ │ │ │ ├── BettingInput │ │ │ │ │ └── index.tsx │ │ │ │ └── TotalBettingDisplay.tsx │ │ │ └── utils │ │ │ │ └── placeBetting.ts │ │ ├── betting-predict-result │ │ │ └── index.tsx │ │ ├── chat │ │ │ ├── hook │ │ │ │ └── useChat.tsx │ │ │ ├── index.tsx │ │ │ ├── provider │ │ │ │ └── ChatProvider.tsx │ │ │ └── ui │ │ │ │ ├── ChatError │ │ │ │ └── index.tsx │ │ │ │ ├── ChatHeader │ │ │ │ ├── index.tsx │ │ │ │ └── ui │ │ │ │ │ ├── ChatTitle.tsx │ │ │ │ │ ├── PredictButton.tsx │ │ │ │ │ └── PredictionStatus.tsx │ │ │ │ ├── ChatInput │ │ │ │ ├── index.tsx │ │ │ │ └── ui │ │ │ │ │ ├── EmoticonButton.tsx │ │ │ │ │ ├── InputBar.tsx │ │ │ │ │ ├── VoteButton.tsx │ │ │ │ │ └── style.module.css │ │ │ │ └── ChatMessages │ │ │ │ ├── index.tsx │ │ │ │ └── ui │ │ │ │ ├── Message.tsx │ │ │ │ └── MessageList.tsx │ │ ├── create-vote │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ ├── api.ts │ │ │ │ ├── helpers │ │ │ │ │ └── formatData.ts │ │ │ │ ├── store.ts │ │ │ │ ├── types.ts │ │ │ │ ├── useCaseInput.ts │ │ │ │ ├── useCoinInput.ts │ │ │ │ ├── useTimer.ts │ │ │ │ ├── useTitleInput.ts │ │ │ │ └── useValidation.ts │ │ │ └── ui │ │ │ │ ├── CreateVotePage.tsx │ │ │ │ ├── components │ │ │ │ ├── CaseInputs.tsx │ │ │ │ ├── CoinInput.tsx │ │ │ │ ├── Timer.tsx │ │ │ │ ├── TitleInput.tsx │ │ │ │ └── index.ts │ │ │ │ └── error │ │ │ │ └── CreateVoteError.tsx │ │ ├── login-page │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ ├── api.ts │ │ │ │ ├── store.ts │ │ │ │ ├── types.ts │ │ │ │ └── validation.ts │ │ │ └── ui │ │ │ │ ├── LoginPage.tsx │ │ │ │ └── components │ │ │ │ ├── GuestLoginForm.tsx │ │ │ │ ├── LoginForm.tsx │ │ │ │ ├── Logout.tsx │ │ │ │ ├── RegisterForm.tsx │ │ │ │ ├── TabButton.tsx │ │ │ │ ├── Warning.tsx │ │ │ │ └── index.ts │ │ ├── my-page │ │ │ ├── error │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── ui │ │ │ │ ├── AnimatedDuckCount.tsx │ │ │ │ ├── FallingDuck.tsx │ │ │ │ └── Pond.tsx │ │ ├── predict-detail │ │ │ ├── index.tsx │ │ │ ├── model │ │ │ │ ├── api.ts │ │ │ │ └── schema.ts │ │ │ └── ui │ │ │ │ ├── AdminBettingResult.tsx │ │ │ │ ├── Bettingstatistics.tsx │ │ │ │ ├── GuestFooter.tsx │ │ │ │ ├── UserBettingResult.tsx │ │ │ │ └── UserFooter.tsx │ │ └── waiting-room │ │ │ ├── error │ │ │ ├── AccessError.ts │ │ │ ├── Forbidden.tsx │ │ │ └── Unauthorized.tsx │ │ │ ├── hooks │ │ │ └── use-waiting-context.tsx │ │ │ ├── index.tsx │ │ │ ├── provider │ │ │ └── WaitingRoomProvider.tsx │ │ │ ├── style.module.css │ │ │ └── ui │ │ │ ├── AdminFooter │ │ │ ├── CancleButton.tsx │ │ │ ├── ParticipateButton.tsx │ │ │ ├── StartVotingButton.tsx │ │ │ └── index.tsx │ │ │ ├── MemberFooter │ │ │ └── index.tsx │ │ │ ├── ParticipantsList.tsx │ │ │ ├── SharedLinkCard.tsx │ │ │ ├── WaitingError │ │ │ └── index.tsx │ │ │ └── WaitingRoomHeader │ │ │ ├── EditFormStatusForm.tsx │ │ │ ├── VotingStatusCard.tsx │ │ │ └── index.tsx │ ├── index.css │ ├── main.tsx │ ├── mocks │ │ ├── browser.ts │ │ └── chat │ │ │ └── handler.ts │ ├── routeTree.gen.ts │ ├── routes │ │ ├── __root.tsx │ │ ├── betting.index.tsx │ │ ├── betting_.$roomId.vote.admin.tsx │ │ ├── betting_.$roomId.vote.resultDetail.tsx │ │ ├── betting_.$roomId.vote.tsx │ │ ├── betting_.$roomId.vote.voting.tsx │ │ ├── betting_.$roomId.waiting.tsx │ │ ├── create-vote.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ ├── my-page.tsx │ │ ├── require-bettingRoomId.tsx │ │ └── require-login.tsx │ ├── shared │ │ ├── api │ │ │ ├── responseBettingRoomInfo.ts │ │ │ ├── responseEndBetroom.ts │ │ │ ├── responseUserInfo.ts │ │ │ └── responseUserToken.ts │ │ ├── components │ │ │ ├── BettingSharedLink │ │ │ │ └── BettingSharedLink.tsx │ │ │ ├── BettingStatsDisplay │ │ │ │ └── BettingStatsDisplay.tsx │ │ │ ├── BettingTimer │ │ │ │ └── BettingTimer.tsx │ │ │ ├── Button │ │ │ │ └── Button.tsx │ │ │ ├── Dialog │ │ │ │ ├── content.tsx │ │ │ │ ├── context.tsx │ │ │ │ ├── focus-lock.tsx │ │ │ │ ├── hook.ts │ │ │ │ ├── index.tsx │ │ │ │ └── trigger.tsx │ │ │ ├── Error │ │ │ │ ├── GlobalError.tsx │ │ │ │ ├── GuestError.tsx │ │ │ │ └── index.tsx │ │ │ ├── Image │ │ │ │ └── index.tsx │ │ │ ├── Loading │ │ │ │ └── index.tsx │ │ │ ├── PercentageDisplay │ │ │ │ └── PercentageDisplay.tsx │ │ │ ├── ProgressBar │ │ │ │ └── index.tsx │ │ │ ├── RootHeader │ │ │ │ └── index.tsx │ │ │ ├── RootSideBar │ │ │ │ ├── index.tsx │ │ │ │ ├── item.tsx │ │ │ │ └── style.module.css │ │ │ └── input │ │ │ │ └── InputField.tsx │ │ ├── config │ │ │ ├── environment.ts │ │ │ └── route.ts │ │ ├── hooks │ │ │ ├── useAuth.ts │ │ │ ├── useBettingRoomInfo.ts │ │ │ ├── useDuckCoin.tsx │ │ │ ├── useEffectOnce.tsx │ │ │ ├── useLayout.tsx │ │ │ ├── useLayoutShift.ts │ │ │ ├── usePreventLeave.ts │ │ │ ├── useSessionStorage.ts │ │ │ ├── useSocketIo.tsx │ │ │ ├── useTrigger.tsx │ │ │ ├── useUserContext.tsx │ │ │ └── useUserInfo.ts │ │ ├── icons │ │ │ ├── ArrowDownIcon.tsx │ │ │ ├── ArrowUpIcon.tsx │ │ │ ├── ChatIcon.tsx │ │ │ ├── ConfirmIcon.tsx │ │ │ ├── CopyIcon.tsx │ │ │ ├── CreateVoteIcon.tsx │ │ │ ├── DuckCoinIcon.tsx │ │ │ ├── DuckIcon.tsx │ │ │ ├── EditIcon.tsx │ │ │ ├── EmailIcon.tsx │ │ │ ├── InfoIcon.tsx │ │ │ ├── LinkIcon.tsx │ │ │ ├── LoginIDIcon.tsx │ │ │ ├── LoginIcon.tsx │ │ │ ├── LoginPasswordIcon.tsx │ │ │ ├── LogoIcon.tsx │ │ │ ├── Logout.tsx │ │ │ ├── PeoplesIcon.tsx │ │ │ ├── TextIcon.tsx │ │ │ ├── TimerIcon.tsx │ │ │ ├── TrophyIcon.tsx │ │ │ ├── UserIcon.tsx │ │ │ ├── WatingRoomIcon.tsx │ │ │ └── index.ts │ │ ├── lib │ │ │ ├── auth │ │ │ │ ├── auth.ts │ │ │ │ ├── authQuery.ts │ │ │ │ └── guard.ts │ │ │ ├── bettingRoomInfo.ts │ │ │ ├── loader │ │ │ │ └── useBetRoomLoader.ts │ │ │ └── validateAccess.ts │ │ ├── misc.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ └── bettingOdds.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── test │ └── sum.test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vite.config.ts.timestamp-1733228994819-67ee7f33c8c0b.mjs ├── nginx.conf ├── nginx.dev.conf ├── nginx.prod.conf ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── redis.conf └── shared ├── .gitignore ├── index.ts ├── package.json ├── schemas ├── bet │ ├── index.ts │ ├── request.ts │ ├── response.ts │ └── socket │ │ ├── request.ts │ │ └── response.ts ├── betresult │ ├── index.ts │ └── response.ts ├── bettingRooms │ ├── index.ts │ ├── request.ts │ └── response.ts ├── chat │ └── socket │ │ ├── reponse.ts │ │ └── request.ts ├── shared.ts └── users │ ├── index.ts │ ├── request.ts │ └── response.ts └── vars ├── index.ts ├── selectedoption.ts ├── status.ts └── user.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.{js,jsx,ts,tsx,css,json,yml,yaml}] 15 | indent_size = 2 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 버그 리포트 (Bug Report) 3 | about: 버그를 보고합니다 4 | title: "[Bug] 버그명 작성" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ### 버그 설명 10 | 11 | - 발생한 버그에 대한 간단한 설명을 작성해주세요. 12 | 13 | ### 재현 방법 14 | 15 | 1. '...로 이동' 16 | 2. '...를 클릭' 17 | 3. '...가 발생' 18 | 19 | ### 기대 동작 20 | 21 | - 예: "정상적으로 로그인 화면이 보여야 한다." 22 | 23 | ### 스크린샷 24 | 25 | - 필요시 스크린샷 첨부 26 | 27 | ### 환경 정보 28 | 29 | - 운영체제: [e.g. macOS] 30 | - 브라우저: [e.g. Chrome 89] 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 기능 요청 (Feature Request) 3 | about: 새로운 기능이나 개선 사항을 요청합니다 4 | title: "[Feature] 기능명 작성" 5 | labels: feature, enhancement 6 | assignees: "" 7 | --- 8 | 9 | ### 기능명 10 | 11 | - 예: 회원가입/로그인 기능ss 12 | 13 | ### 사용자 스토리 14 | 15 | - 예: "사용자는 이메일과 비밀번호를 통해 회원가입과 로그인을 할 수 있어야 한다." 16 | 17 | ### 요구사항 18 | 19 | - [ ] 이메일 형식 유효성 검사 20 | - [ ] 암호화된 비밀번호 저장 21 | - [ ] API 연동 22 | 23 | ### 우선순위 24 | 25 | - 우선순위 수준: `High`, `Medium`, `Low` 26 | 27 | ### 예상 소요 시간 28 | 29 | - 예: 3일 30 | 31 | ### 리스크 및 장애 요소 32 | 33 | - 예: 외부 인증 API 호출 한도 초과 가능성 34 | 35 | ### 추가 정보 36 | 37 | - 참고 사항, 디자인, 기타 링크 등을 추가하세요. 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 🔍 이슈 2 | 3 | ## 📗 작업 내역 4 | 5 | > 구현한 작업 내역 6 | 7 | ## 📸 스크린샷 (Optional) 8 | 9 | | 기능 | 스크린샷 | 10 | | ---- | -------- | 11 | | | | 12 | 13 | ## 📋 PR 유형 14 | 15 | - [ ] 기능 추가 16 | - [ ] 버그 수정 17 | - [ ] UI 디자인 변경 18 | - [ ] 코드에 영향을 주지 않은 변경 사항(오타 수정, 탭 사이즈 변경, 변수명 변경) 19 | - [ ] 코드 리팩토링 20 | - [ ] 주석 추가 및 수정 21 | - [ ] 문서 수정 22 | - [ ] 테스트 추가, 테스트 리팩토링 23 | - [ ] 빌드 부분 혹은 패키지 매니저 수정 24 | - [ ] 파일 혹은 폴더명 수정 25 | - [ ] 파일 혹은 폴더 삭제 26 | - [ ] 파일 혹은 폴더 추가 27 | 28 | ## ⚠️ 추가적으로 설명할 내용 29 | -------------------------------------------------------------------------------- /.github/workflows/backend-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Backend Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | paths: 8 | - "backend/**" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | 18 | # NestJS 빌드 과정 19 | 20 | # - name: Set up Node.js 21 | # uses: actions/setup-node@v2 22 | # with: 23 | # node-version-file: ".nvmrc" 24 | 25 | # - name: Install pnpm 26 | # run: npm install -g pnpm 27 | 28 | # - name: Install dependencies 29 | # run: pnpm install 30 | # working-directory: ${{ vars.BACK_SERVICE_PATH }} 31 | 32 | # - name: Build the project 33 | # run: pnpm run build 34 | 35 | - name: Setup SSH 36 | run: | 37 | echo "${{ secrets.SSH_PEM_KEY }}" >> /tmp/deploy_key.pem && \ 38 | chmod 400 /tmp/deploy_key.pem && \ 39 | eval "$(ssh-agent -s)" && \ 40 | ssh-add /tmp/deploy_key.pem 41 | 42 | - name: Deploy to server 43 | run: | 44 | ssh -i /tmp/deploy_key.pem -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }} << EOF 45 | echo "Connected to the server" 46 | cd /app/web14-betting-duck 47 | git pull origin dev 48 | cd /app/web14-betting-duck/backend 49 | docker restart betting_duck_app 50 | EOF 51 | 52 | - name: Notify deployment status 53 | if: success() 54 | run: echo "Deployment succeeded." 55 | 56 | - name: Notify deployment failure 57 | if: failure() 58 | run: echo "Deployment failed." 59 | -------------------------------------------------------------------------------- /.github/workflows/backend-test.yml: -------------------------------------------------------------------------------- 1 | name: Backend Test 2 | 3 | on: 4 | push: 5 | paths: 6 | - "backend/**" 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version-file: ".nvmrc" 19 | 20 | - name: Install pnpm 21 | run: npm install -g pnpm 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | working-directory: ${{ vars.BACK_SERVICE_PATH }} 26 | 27 | - name: Run tests 28 | run: pnpm test 29 | working-directory: ${{ vars.BACK_SERVICE_PATH }} 30 | # 리포지토리 Settings > Secrets and variables > Actions > Variables 31 | # AUTH_SERVICE_PATH: ./backend/auth-service 32 | # BACK_SERVICE_PATH: ./backend -------------------------------------------------------------------------------- /.github/workflows/frontend-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Frontend Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | paths: 8 | - "frontend/**" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup SSH 19 | run: | 20 | echo "${{ secrets.SSH_PEM_KEY }}" >> /tmp/deploy_key.pem && \ 21 | chmod 400 /tmp/deploy_key.pem && \ 22 | eval "$(ssh-agent -s)" && \ 23 | ssh-add /tmp/deploy_key.pem 24 | 25 | - name: Deploy to server 26 | run: | 27 | ssh -i /tmp/deploy_key.pem -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }} << EOF 28 | echo "Connected to the server" 29 | cd /app/web14-betting-duck 30 | git pull origin dev 31 | docker exec betting_duck_app sh -c "cd /app/frontend; pnpm install; pnpm build" 32 | EOF 33 | 34 | - name: Notify deployment status 35 | if: success() 36 | run: echo "Deployment succeeded." 37 | 38 | - name: Notify deployment failure 39 | if: failure() 40 | run: echo "Deployment failed." 41 | -------------------------------------------------------------------------------- /.github/workflows/frontend-test.yml: -------------------------------------------------------------------------------- 1 | name: Frontend Test 2 | 3 | on: 4 | push: 5 | paths: 6 | - "frontend/**" 7 | 8 | jobs: 9 | frontend-test: 10 | name: Frontend Test 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: frontend 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version-file: .nvmrc 22 | 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: 9 27 | run_install: false 28 | 29 | - name: Install dependencies 30 | run: pnpm install 31 | 32 | - name: Run tests 33 | run: pnpm test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 관련 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | package-lock.json 7 | .pnpm-debug.log* 8 | .vscode/ 9 | 10 | # 환경 변수 및 설정 11 | .env 12 | .env.local 13 | .env.*.local 14 | 15 | # 빌드 파일 16 | dist/ 17 | build/ 18 | out/ 19 | coverage/ 20 | 21 | # 운영체제 파일 22 | .DS_Store 23 | Thumbs.db 24 | 25 | # IDE 및 편집기 설정 26 | .idea/ 27 | .vscode/ 28 | *.swp 29 | *.swo 30 | *.swn 31 | 32 | # huksy 33 | .husky/_ 34 | 35 | #pnpm 36 | .pnpm-store/ 37 | 38 | #db 39 | db-init.sql/ 40 | 41 | #nginx 42 | nginx.conf/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | cd frontend 2 | pnpm build 3 | cd .. -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) 4 | 5 | ISSUE_NUMBER=$(echo $BRANCH_NAME | grep -o 'issue-[0-9]*' | sed 's/issue-//') 6 | 7 | if [ -z "$ISSUE_NUMBER" ]; then 8 | exit 0 9 | fi 10 | 11 | echo "[#$ISSUE_NUMBER] " >> "$1" -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # 모노레포에서 안정적인 패키지 관리를 위한 권장 설정 2 | enable-pre-post-scripts=true # 빌드 스크립트 필요 시 3 | node-linker=isolated # 패키지 독립성 보장 4 | strict-peer-dependencies=false # 개발 편의성 5 | auto-install-peers=true # 의존성 자동 관리 6 | prefer-workspace-packages=true # 로컬 패키지 우선 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.11.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "plugins": ["prettier-plugin-tailwindcss"] 7 | } 8 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /backend/jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["js", "json", "ts"], 3 | rootDir: ".", 4 | testRegex: ".*\\.spec\\.ts$", 5 | transform: { 6 | "^.+\\.(t|j)s$": "ts-jest", 7 | }, 8 | moduleNameMapper: { 9 | "^src/(.*)$": "/src/$1" 10 | }, 11 | testEnvironment: "node", 12 | }; -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "entryFile": "backend/src/main", 6 | "compilerOptions": { 7 | "deleteOutDir": true, 8 | "tsConfigPath": "./tsconfig.json" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { AppController } from "./app.controller"; 3 | import { AppService } from "./app.service"; 4 | 5 | describe("AppController", () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe("root", () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe("Hello World!"); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common"; 2 | import { AppService } from "./app.service"; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ConfigModule, ConfigService } from "@nestjs/config"; 3 | import { TypeOrmModule } from "@nestjs/typeorm"; 4 | import { typeORMConfig } from "./config/typeorm.config"; 5 | import { RedisModule } from "@liaoliaots/nestjs-redis"; 6 | import { redisConfig } from "./config/redis.config"; 7 | import { UserModule } from "./auth/user.module"; 8 | import { AppController } from "./app.controller"; 9 | import { AppService } from "./app.service"; 10 | import { ChatModule } from "./chat/chat.module"; 11 | import { BetModule } from "./bet/bet.module"; 12 | import { BetRoomModule } from "./bet-room/bet-room.module"; 13 | import { BetResultModule } from "./bet-result/bet-result.module"; 14 | import { DBManagerModule } from "./utils/db.manager.module"; 15 | 16 | @Module({ 17 | imports: [ 18 | ConfigModule.forRoot({ 19 | isGlobal: true, 20 | }), 21 | TypeOrmModule.forRootAsync({ 22 | inject: [ConfigService], 23 | useFactory: async (configService: ConfigService) => 24 | await typeORMConfig(configService), 25 | }), 26 | RedisModule.forRootAsync({ 27 | useFactory: redisConfig, 28 | inject: [ConfigService], 29 | }), 30 | UserModule, 31 | ChatModule, 32 | BetModule, 33 | BetRoomModule, 34 | BetResultModule, 35 | DBManagerModule, 36 | ], 37 | controllers: [AppController], 38 | providers: [AppService], 39 | }) 40 | export class AppModule {} 41 | -------------------------------------------------------------------------------- /backend/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return "Hello World!"; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/auth/dto/guest-sign-in-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from "nestjs-zod"; 2 | import { requestGuestSignInSchema } from "@shared/schemas/users/request"; 3 | 4 | export class GuestSignInUserDto extends createZodDto( 5 | requestGuestSignInSchema, 6 | ) {} 7 | -------------------------------------------------------------------------------- /backend/src/auth/dto/sign-in-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from "nestjs-zod"; 2 | import { requestSignInSchema } from "@shared/schemas/users/request"; 3 | 4 | export class SignInUserRequestDto extends createZodDto(requestSignInSchema) {} 5 | -------------------------------------------------------------------------------- /backend/src/auth/dto/sign-up-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from "nestjs-zod"; 2 | import { requestSignUpSchema } from "@shared/schemas/users/request"; 3 | import { responseSignUpSchema } from "@shared/schemas/users/response"; 4 | 5 | export class SignUpUserRequestDto extends createZodDto(requestSignUpSchema) {} 6 | 7 | export class SignUpUserResponseDto extends createZodDto(responseSignUpSchema) {} 8 | -------------------------------------------------------------------------------- /backend/src/auth/dto/upgrade-guest.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from "nestjs-zod"; 2 | import { requestUpgradeGuest } from "@shared/schemas/users/request"; 3 | 4 | export class UpgradeGuestRequestDto extends createZodDto(requestUpgradeGuest) {} 5 | -------------------------------------------------------------------------------- /backend/src/auth/user-credential.dto.ts: -------------------------------------------------------------------------------- 1 | export class UserCredentialsDto { 2 | email: string; 3 | nickname: string; 4 | password: string; 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/auth/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | OneToMany, 7 | CreateDateColumn, 8 | } from "typeorm"; 9 | import { Bet } from "src/bet/bet.entity"; 10 | import { BetRoom } from "src/bet-room/bet-room.entity"; 11 | 12 | @Entity("users") 13 | export class User extends BaseEntity { 14 | @PrimaryGeneratedColumn("increment") 15 | id: number; 16 | 17 | @Column({ unique: true }) 18 | email: string; 19 | 20 | @Column({ unique: true }) 21 | nickname: string; 22 | 23 | @Column() 24 | password: string; 25 | 26 | @Column() 27 | duck: number; 28 | 29 | @Column() 30 | realDuck: number; 31 | 32 | @CreateDateColumn({ type: "timestamp", nullable: true }) 33 | created_at: Date; 34 | 35 | @OneToMany(() => Bet, (bet) => bet.user) 36 | bets: Bet[]; 37 | 38 | @OneToMany(() => BetRoom, (betRoom) => betRoom.manager) 39 | managedBetRooms: BetRoom[]; 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/auth/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { UserController } from "./user.controller"; 4 | import { UserService } from "./user.service"; 5 | import { UserRepository } from "./user.repository"; 6 | import { User } from "./user.entity"; 7 | import { JwtModule } from "@nestjs/jwt"; 8 | import { RedisManagerModule } from "src/utils/redis-manager.module"; 9 | 10 | import { DBManagerModule } from "src/utils/db.manager.module"; 11 | import { Bet } from "src/bet/bet.entity"; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([User]), 16 | JwtModule.register({ 17 | secret: process.env.JWT_SECRET || "secret", 18 | signOptions: { expiresIn: "24h" }, //임시 개발 환경을 위한 설정 19 | }), 20 | TypeOrmModule.forFeature([User, Bet]), 21 | RedisManagerModule, 22 | DBManagerModule, 23 | ], 24 | controllers: [UserController], 25 | providers: [UserService, UserRepository], 26 | exports: [UserRepository], 27 | }) 28 | export class UserModule {} 29 | -------------------------------------------------------------------------------- /backend/src/bet-result/bet-result.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | HttpStatus, 4 | Get, 5 | Res, 6 | Param, 7 | UseGuards, 8 | } from "@nestjs/common"; 9 | import { Response } from "express"; 10 | import { BetResultService } from "./bet-result.service"; 11 | import { JwtGuestAuthGuard } from "src/utils/guards/http-guest-authenticated.guard"; 12 | 13 | @Controller("/api/betresults") 14 | export class BetResultController { 15 | constructor(private betResultService: BetResultService) {} 16 | 17 | @UseGuards(JwtGuestAuthGuard) 18 | @Get("/:betRoomId") 19 | async getBetResult( 20 | @Param("betRoomId") betRoomId: string, 21 | @Res() res: Response, 22 | ) { 23 | try { 24 | const betResult = await this.betResultService.findBetRoomById(betRoomId); 25 | return res.status(HttpStatus.OK).json({ 26 | status: HttpStatus.OK, 27 | data: betResult, 28 | }); 29 | } catch (error) { 30 | return res.status(error.status || HttpStatus.INTERNAL_SERVER_ERROR).json({ 31 | status: error.status || HttpStatus.INTERNAL_SERVER_ERROR, 32 | data: { 33 | message: error.message || "Internal Server Error", 34 | }, 35 | }); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/bet-result/bet-result.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | OneToOne, 7 | CreateDateColumn, 8 | JoinColumn, 9 | } from "typeorm"; 10 | 11 | import { BetRoom } from "../bet-room/bet-room.entity"; 12 | 13 | @Entity("bet_results") 14 | export class BetResult extends BaseEntity { 15 | @PrimaryGeneratedColumn("increment") 16 | id: number; 17 | 18 | @OneToOne(() => BetRoom, (betRoom) => betRoom.betResult) 19 | @JoinColumn() 20 | betRoom: BetRoom; 21 | 22 | @Column() 23 | option1TotalBet: number; 24 | 25 | @Column() 26 | option2TotalBet: number; 27 | 28 | @Column() 29 | option1TotalParticipants: number; 30 | 31 | @Column() 32 | option2TotalParticipants: number; 33 | 34 | @Column({ type: "enum", enum: ["option1", "option2"], nullable: true }) 35 | winningOption: "option1" | "option2"; 36 | 37 | @CreateDateColumn({ type: "timestamp", nullable: true }) 38 | createdAt: Date; 39 | 40 | @Column({ type: "enum", enum: ["settled", "refunded"] }) 41 | status: "settled" | "refunded"; 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/bet-result/bet-result.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { BetResultRepository } from "src/bet-result/bet-result.repository"; 4 | import { BetResult } from "./bet-result.entity"; 5 | import { BetResultController } from "./bet-result.controller"; 6 | import { BetResultService } from "./bet-result.service"; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([BetResult])], 10 | controllers: [BetResultController], 11 | providers: [BetResultService, BetResultRepository], 12 | }) 13 | export class BetResultModule {} 14 | -------------------------------------------------------------------------------- /backend/src/bet-result/bet-result.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InternalServerErrorException, 3 | NotFoundException, 4 | Injectable, 5 | } from "@nestjs/common"; 6 | import { Repository } from "typeorm"; 7 | import { InjectRepository } from "@nestjs/typeorm"; 8 | import { BetResult } from "./bet-result.entity"; 9 | 10 | @Injectable() 11 | export class BetResultRepository { 12 | constructor( 13 | @InjectRepository(BetResult) 14 | private betResultRepository: Repository, 15 | ) {} 16 | 17 | async saveBetResult(betResultData: Partial): Promise { 18 | const betResult = this.betResultRepository.create(betResultData); 19 | try { 20 | return await this.betResultRepository.save(betResult); 21 | } catch { 22 | throw new InternalServerErrorException("베팅 결과 저장이 실패했습니다."); 23 | } 24 | } 25 | 26 | async findByBetRoomId(betRoomId: string): Promise { 27 | try { 28 | const betResult = await this.betResultRepository.findOne({ 29 | where: { betRoom: { id: betRoomId } }, 30 | relations: ["betRoom"], 31 | }); 32 | if (!betResult) { 33 | throw new NotFoundException( 34 | `베팅방 아이디에 해당하는 베팅 결과가 없습니다. ${betRoomId}`, 35 | ); 36 | } 37 | return betResult; 38 | } catch { 39 | throw new InternalServerErrorException("베팅 방 조회에 실패했습니다."); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/bet-result/bet-result.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { BetResultRepository } from "src/bet-result/bet-result.repository"; 3 | 4 | @Injectable() 5 | export class BetResultService { 6 | constructor(private betResultRepository: BetResultRepository) {} 7 | 8 | async findBetRoomById(betRoomId: string) { 9 | const betResult = await this.betResultRepository.findByBetRoomId(betRoomId); 10 | return { 11 | option_1_total_bet: betResult.option1TotalBet, 12 | option_2_total_bet: betResult.option2TotalBet, 13 | option_1_total_participants: betResult.option1TotalParticipants, 14 | option_2_total_participants: betResult.option2TotalParticipants, 15 | winning_option: betResult.winningOption, 16 | message: "OK", 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/bet-room/bet-room.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | PrimaryColumn, 6 | OneToMany, 7 | OneToOne, 8 | ManyToOne, 9 | CreateDateColumn, 10 | UpdateDateColumn, 11 | } from "typeorm"; 12 | import { User } from "src/auth/user.entity"; 13 | import { Bet } from "../bet/bet.entity"; 14 | import { BetResult } from "../bet-result/bet-result.entity"; 15 | 16 | @Entity("bet_rooms") 17 | export class BetRoom extends BaseEntity { 18 | @PrimaryColumn("uuid") 19 | id: string; 20 | 21 | @ManyToOne(() => User, (user) => user.managedBetRooms) 22 | manager: User; 23 | 24 | @Column({ length: 300 }) 25 | title: string; 26 | 27 | @Column() 28 | defaultBetAmount: number; 29 | 30 | @Column({ length: 200 }) 31 | option1: string; 32 | 33 | @Column({ length: 200 }) 34 | option2: string; 35 | 36 | @Column({ type: "timestamp", nullable: true }) 37 | startTime: Date; 38 | 39 | @Column({ type: "timestamp", nullable: true }) 40 | endTime: Date; 41 | 42 | @Column({ type: "enum", enum: ["waiting", "active", "timeover", "finished"] }) 43 | status: "waiting" | "active" | "timeover" | "finished"; 44 | 45 | @CreateDateColumn({ type: "timestamp" }) 46 | createdAt: Date; 47 | 48 | @UpdateDateColumn({ type: "timestamp", nullable: true }) 49 | updatedAt: Date; 50 | 51 | @Column({ length: 200 }) 52 | joinUrl: string; 53 | 54 | @Column() 55 | timer: number; 56 | 57 | @OneToMany(() => Bet, (bet) => bet.betRoom) 58 | bets: Bet[]; 59 | 60 | @OneToOne(() => BetResult, (betResult) => betResult.betRoom) 61 | betResult: BetResult; 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/bet-room/bet-room.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { BetRoomController } from "./bet-room.controller"; 4 | import { BetRoomService } from "./bet-room.service"; 5 | import { BetRoomRepository } from "./bet-room.repository"; 6 | import { UserRepository } from "src/auth/user.repository"; 7 | import { BetRoom } from "./bet-room.entity"; 8 | import { User } from "src/auth/user.entity"; 9 | import { RedisManagerModule } from "src/utils/redis-manager.module"; 10 | import { BetResultRepository } from "src/bet-result/bet-result.repository"; 11 | import { BetResult } from "src/bet-result/bet-result.entity"; 12 | import { BetModule } from "src/bet/bet.module"; 13 | import { DBManagerModule } from "src/utils/db.manager.module"; 14 | import { Bet } from "src/bet/bet.entity"; 15 | import { BetRoomRefundService } from "./bet-room.refund.service"; 16 | 17 | @Module({ 18 | imports: [ 19 | TypeOrmModule.forFeature([BetRoom, User, BetResult, Bet]), 20 | RedisManagerModule, 21 | BetModule, 22 | DBManagerModule, 23 | ], 24 | controllers: [BetRoomController], 25 | providers: [ 26 | BetRoomService, 27 | BetRoomRepository, 28 | UserRepository, 29 | BetResultRepository, 30 | BetRoomRefundService, 31 | ], 32 | }) 33 | export class BetRoomModule {} 34 | -------------------------------------------------------------------------------- /backend/src/bet-room/dto/create-bet-room.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateBetRoomDto { 2 | channel: { 3 | title: string; 4 | options: { 5 | option1: string; 6 | option2: string; 7 | }; 8 | settings: { 9 | duration: number; 10 | defaultBetAmount: number; 11 | // maxParticipants: number; 12 | }; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/bet-room/dto/update-bet-room.dto.ts: -------------------------------------------------------------------------------- 1 | export class UpdateBetRoomDto { 2 | channel?: { 3 | title?: string; 4 | options?: { 5 | option1?: string; 6 | option2?: string; 7 | }; 8 | settings?: { 9 | duration?: number; 10 | defaultBetAmount?: number; 11 | // maxParticipants?: number; 12 | }; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/bet/bet.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | HttpStatus, 4 | Res, 5 | Req, 6 | UseGuards, 7 | Post, 8 | Body, 9 | Get, 10 | Param, 11 | } from "@nestjs/common"; 12 | import { Response } from "express"; 13 | import { JwtGuestAuthGuard } from "src/utils/guards/http-guest-authenticated.guard"; 14 | import { BetService } from "./bet.service"; 15 | import { PlaceBetDto } from "./dto/placeBetDto"; 16 | 17 | @Controller("/api/bets") 18 | export class BetController { 19 | constructor(private betService: BetService) {} 20 | 21 | @UseGuards(JwtGuestAuthGuard) 22 | @Post() 23 | async placeBet( 24 | @Body() placeBetDto: PlaceBetDto, 25 | @Res() res: Response, 26 | @Req() req: Request, 27 | ) { 28 | try { 29 | const bet = await this.betService.placeBet(req, placeBetDto); 30 | return res.status(HttpStatus.OK).json({ 31 | status: HttpStatus.OK, 32 | data: bet, 33 | }); 34 | } catch (error) { 35 | return res.status(error.status || HttpStatus.INTERNAL_SERVER_ERROR).json({ 36 | status: error.status || HttpStatus.INTERNAL_SERVER_ERROR, 37 | data: { 38 | message: error.message || "Internal Server Error", 39 | }, 40 | }); 41 | } 42 | } 43 | 44 | @UseGuards(JwtGuestAuthGuard) 45 | @Get("/:betRoomId") 46 | async getBetDetails( 47 | @Param("betRoomId") betRoomId: string, 48 | @Res() res: Response, 49 | @Req() req: Request, 50 | ) { 51 | try { 52 | const betDetails = await this.betService.getBetDetail(req, betRoomId); 53 | return res.status(HttpStatus.OK).json({ 54 | status: HttpStatus.OK, 55 | data: betDetails, 56 | }); 57 | } catch (error) { 58 | return res.status(error.status || HttpStatus.INTERNAL_SERVER_ERROR).json({ 59 | status: error.status || HttpStatus.INTERNAL_SERVER_ERROR, 60 | data: { 61 | message: error.message || "Internal Server Error", 62 | }, 63 | }); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /backend/src/bet/bet.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | ManyToOne, 7 | CreateDateColumn, 8 | } from "typeorm"; 9 | import { User } from "src/auth/user.entity"; 10 | import { BetRoom } from "../bet-room/bet-room.entity"; 11 | 12 | @Entity("bets") 13 | export class Bet extends BaseEntity { 14 | @PrimaryGeneratedColumn("increment") 15 | id: number; 16 | 17 | @ManyToOne(() => User, (user) => user.bets) 18 | user: User; 19 | 20 | @ManyToOne(() => BetRoom, (betRoom) => betRoom.bets) 21 | betRoom: BetRoom; 22 | 23 | @Column() 24 | betAmount: number; 25 | 26 | @Column({ nullable: true }) 27 | settledAmount?: number; 28 | 29 | @Column({ 30 | type: "enum", 31 | enum: ["pending", "settled", "refunded"], 32 | default: "pending", 33 | }) 34 | status: "pending" | "settled" | "refunded"; 35 | 36 | @CreateDateColumn({ type: "timestamp" }) 37 | createdAt: Date; 38 | 39 | @Column({ type: "enum", enum: ["option1", "option2"] }) 40 | selectedOption: "option1" | "option2"; 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/bet/bet.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { BetGateway } from "./bet.gateway"; 4 | import { RedisManagerModule } from "src/utils/redis-manager.module"; 5 | import { JwtUtils } from "src/utils/jwt.utils"; 6 | import { BetService } from "./bet.service"; 7 | import { BetRepository } from "./bet.repository"; 8 | import { Bet } from "./bet.entity"; 9 | import { BetController } from "./bet.controller"; 10 | import { UserModule } from "src/auth/user.module"; 11 | 12 | @Module({ 13 | imports: [TypeOrmModule.forFeature([Bet]), RedisManagerModule, UserModule], 14 | controllers: [BetController], 15 | providers: [BetGateway, JwtUtils, BetService, BetRepository], 16 | exports: [BetGateway, BetRepository], 17 | }) 18 | export class BetModule {} 19 | -------------------------------------------------------------------------------- /backend/src/bet/dto/placeBetDto.ts: -------------------------------------------------------------------------------- 1 | export class PlaceBetDto { 2 | sender: { 3 | betAmount: number; 4 | selectOption: "option1" | "option2"; 5 | }; 6 | channel: { 7 | roomId: string; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ChatGateway } from "./chat.gateway"; 3 | import { RedisManagerModule } from "src/utils/redis-manager.module"; 4 | 5 | @Module({ 6 | imports: [RedisManagerModule], 7 | providers: [ChatGateway], 8 | }) 9 | export class ChatModule {} 10 | -------------------------------------------------------------------------------- /backend/src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import { RedisModuleOptions } from "@liaoliaots/nestjs-redis"; 3 | 4 | export const redisConfig = async ( 5 | configService: ConfigService, 6 | ): Promise => { 7 | const REDIS_HOSTNAME = 8 | configService.get("REDIS_HOSTNAME") || "localhost"; 9 | const REDIS_PORT = configService.get("REDIS_PORT") || 6379; 10 | 11 | return { 12 | config: [ 13 | { 14 | namespace: "default", // client 15 | host: REDIS_HOSTNAME, 16 | port: REDIS_PORT, 17 | }, 18 | { 19 | namespace: "publisher", 20 | host: REDIS_HOSTNAME, 21 | port: REDIS_PORT, 22 | }, 23 | { 24 | namespace: "consumer", 25 | host: REDIS_HOSTNAME, 26 | port: REDIS_PORT, 27 | }, 28 | ], 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /backend/src/config/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; 3 | import { patchNestJsSwagger } from "nestjs-zod"; 4 | 5 | export const swaggerConfig = (app: INestApplication): void => { 6 | patchNestJsSwagger(); 7 | const options = new DocumentBuilder() 8 | .setTitle("Betting Duck API Docs") 9 | .setDescription("Betting Duck API description") 10 | .setVersion("1.0.0") 11 | .build(); 12 | 13 | const document = SwaggerModule.createDocument(app, options); 14 | SwaggerModule.setup("api/docs", app, document); 15 | }; 16 | -------------------------------------------------------------------------------- /backend/src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from "@nestjs/typeorm"; 2 | import { ConfigService } from "@nestjs/config"; 3 | 4 | export const typeORMConfig = async ( 5 | configService: ConfigService, 6 | ): Promise => { 7 | return { 8 | type: "postgres", 9 | host: 10 | process.env.POSTGRES_HOSTNAME || 11 | configService.get("POSTGRES_HOSTNAME"), 12 | port: process.env.POSTGRES_PORT 13 | ? parseInt(process.env.POSTGRES_PORT, 10) 14 | : configService.get("POSTGRES_PORT"), 15 | username: 16 | process.env.POSTGRES_USERNAME || 17 | configService.get("POSTGRES_USERNAME"), 18 | password: 19 | process.env.POSTGRES_PASSWORD || 20 | configService.get("POSTGRES_PASSWORD"), 21 | database: 22 | process.env.POSTGRES_DB_NAME || 23 | configService.get("POSTGRES_DB_NAME"), 24 | entities: [__dirname + "/../**/*.entity.{js,ts}"], 25 | synchronize: process.env.POSTGRES_DB_NAME_DB_SYNCHRONIZE 26 | ? process.env.POSTGRES_DB_NAME_DB_SYNCHRONIZE.toLowerCase() === "true" 27 | : configService.get("POSTGRES_DB_NAME_DB_SYNCHRONIZE"), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from "@nestjs/core"; 2 | import { AppModule } from "./app.module"; 3 | import { swaggerConfig } from "./config/swagger.config"; 4 | import { GlobalHttpExceptionFilter } from "./utils/filters/global-http-exception.filter"; 5 | import { LoggerMiddleware } from "./utils/middlewares/logger.middleware"; 6 | import * as cookieParser from "cookie-parser"; 7 | import { RedisService } from "@liaoliaots/nestjs-redis"; 8 | import { RedisIoAdapter } from "./utils/redis-io-adapter"; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | 13 | const redisService = app.get(RedisService); 14 | const redisIoAdapter = new RedisIoAdapter(app, redisService); 15 | app.useWebSocketAdapter(redisIoAdapter); 16 | 17 | swaggerConfig(app); 18 | app.useGlobalFilters(new GlobalHttpExceptionFilter()); 19 | app.use(new LoggerMiddleware().use); 20 | app.use(cookieParser()); 21 | 22 | await app.listen(process.env.PORT ?? 3000, "0.0.0.0"); 23 | } 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /backend/src/utils/db.manager.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { RedisManagerModule } from "src/utils/redis-manager.module"; 4 | import { User } from "src/auth/user.entity"; 5 | import { Bet } from "src/bet/bet.entity"; 6 | import { DBManager } from "./db.manager"; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User, Bet]), RedisManagerModule], 10 | providers: [DBManager], 11 | exports: [DBManager], 12 | }) 13 | export class DBManagerModule {} 14 | -------------------------------------------------------------------------------- /backend/src/utils/filters/global-http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | } from "@nestjs/common"; 8 | import { Response } from "express"; 9 | 10 | @Catch() 11 | export class GlobalHttpExceptionFilter implements ExceptionFilter { 12 | catch(exception: unknown | object, host: ArgumentsHost) { 13 | const ctx = host.switchToHttp(); 14 | const response = ctx.getResponse(); 15 | 16 | const statusCode = 17 | exception instanceof HttpException 18 | ? exception.getStatus() 19 | : HttpStatus.INTERNAL_SERVER_ERROR; 20 | 21 | const errorMessage = 22 | exception instanceof HttpException 23 | ? exception.getResponse() 24 | : exception || "Internal server error"; 25 | 26 | response.status(statusCode).json({ 27 | statusCode: statusCode, 28 | data: { 29 | message: errorMessage, 30 | }, 31 | timestamp: new Date().toISOString(), 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/utils/filters/global-ws-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, WsExceptionFilter } from "@nestjs/common"; 2 | import { WsException } from "@nestjs/websockets"; 3 | 4 | @Catch() 5 | export class GlobalWsExceptionFilter implements WsExceptionFilter { 6 | catch(exception: WsException | object, host: ArgumentsHost) { 7 | const ctx = host.switchToWs(); 8 | const client = ctx.getClient(); 9 | 10 | const errorMessage = 11 | exception instanceof WsException 12 | ? exception.message 13 | : exception || "Internal server error"; 14 | 15 | client.emit("error", { message: errorMessage }); 16 | 17 | console.log(exception); 18 | 19 | // TODO: 상위로 에러 전파 20 | // throw exception; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/utils/guards/http-guest-authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { Request } from "express"; 3 | import * as jwt from "jsonwebtoken"; 4 | 5 | @Injectable() 6 | export class JwtGuestAuthGuard implements CanActivate { 7 | canActivate(context: ExecutionContext): boolean { 8 | const request = context.switchToHttp().getRequest(); 9 | const accessToken = this.extractAccessToken(request); 10 | 11 | if (!accessToken) { 12 | return false; 13 | } 14 | 15 | try { 16 | const payload = this.verifyToken(accessToken); 17 | 18 | if (!this.isValidRole(payload.role)) { 19 | return false; 20 | } 21 | 22 | request["user"] = payload; 23 | return true; 24 | } catch (err) { 25 | console.error("Token verification error:", err); 26 | return false; 27 | } 28 | } 29 | 30 | private extractAccessToken(request: Request): string | undefined { 31 | return request.cookies["access_token"]; 32 | } 33 | 34 | private verifyToken(token: string) { 35 | return jwt.verify(token, process.env.JWT_SECRET || "secret"); 36 | } 37 | 38 | private isValidRole(role: string): boolean { 39 | return role === "user" || role === "guest"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/utils/guards/http-user-authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { Request } from "express"; 3 | import * as jwt from "jsonwebtoken"; 4 | 5 | @Injectable() 6 | export class JwtUserAuthGuard implements CanActivate { 7 | canActivate(context: ExecutionContext): boolean { 8 | const request = context.switchToHttp().getRequest(); 9 | const accessToken = this.extractAccessToken(request); 10 | 11 | if (!accessToken) { 12 | return false; 13 | } 14 | 15 | try { 16 | const payload = this.verifyToken(accessToken); 17 | 18 | if (!this.isUserRole(payload.role)) { 19 | return false; 20 | } 21 | 22 | request["user"] = { ...payload, id: Number(payload.id) }; 23 | return true; 24 | } catch (err) { 25 | console.error("Token verification error:", err); 26 | return false; 27 | } 28 | } 29 | 30 | private extractAccessToken(request: Request): string | undefined { 31 | return request.cookies["access_token"]; 32 | } 33 | 34 | private verifyToken(token: string) { 35 | return jwt.verify(token, process.env.JWT_SECRET || "secret"); 36 | } 37 | 38 | private isUserRole(role: string): boolean { 39 | return role === "user"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/utils/guards/ws-authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | // import { Observable } from 'rxjs'; 3 | import { Socket } from "socket.io"; 4 | 5 | @Injectable() 6 | export class AuthenticatedGuard implements CanActivate { 7 | canActivate(context: ExecutionContext): boolean { 8 | const client: Socket = context.switchToWs().getClient(); 9 | console.log( 10 | "AuthenticatedGuard(테스트용 로그, client IP) : ", 11 | client.handshake.headers["x-real-ip"], 12 | ); 13 | /* 14 | client.handshake 구조 15 | 16 | { 17 | headers: { 18 | upgrade: 'websocket', 19 | connection: 'Upgrade', 20 | host: 'localhost', 21 | 'x-real-ip': '192.168.176.1', 22 | 'x-forwarded-for': '192.168.176.1', 23 | 'x-forwarded-proto': 'http', 24 | 'sec-websocket-version': '13', 25 | 'sec-websocket-key': 'aTecGCs4/MMBaKgMje1nsg==', 26 | 'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits' 27 | }, 28 | time: 'Fri Nov 15 2024 06:26:25 GMT+0000 (Coordinated Universal Time)', 29 | address: '192.168.176.5', 30 | xdomain: false, 31 | secure: false, 32 | issued: 1731651985286, 33 | url: '/socket.io/?EIO=4&transport=websocket', 34 | query: [Object: null prototype] { EIO: '4', transport: 'websocket' }, 35 | } 36 | */ 37 | 38 | // TODO: 토큰 검증 로직 39 | 40 | return true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/utils/jwt.utils.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from "jsonwebtoken"; 2 | 3 | export class JwtUtils { 4 | verifyToken(token: string) { 5 | return jwt.verify(token, process.env.JWT_SECRET || "secret"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/utils/middlewares/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | import { Request, Response, NextFunction } from "express"; 3 | 4 | @Injectable() 5 | export class LoggerMiddleware implements NestMiddleware { 6 | use(req: Request, res: Response, next: NextFunction) { 7 | const { method, originalUrl } = req; 8 | const start = Date.now(); 9 | 10 | res.on("finish", () => { 11 | const duration = Date.now() - start; 12 | const timestamp = new Date().toLocaleString("en-US", { 13 | year: "numeric", 14 | month: "2-digit", 15 | day: "2-digit", 16 | hour: "2-digit", 17 | minute: "2-digit", 18 | second: "2-digit", 19 | hour12: false, 20 | }); 21 | console.log( 22 | `[Nest] ${process.pid} - ${timestamp} LOG [LoggerMiddleware] Mapped {${originalUrl}, ${method}} route +${duration}ms`, 23 | ); 24 | }); 25 | 26 | next(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/utils/redis-io-adapter.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from "@nestjs/common"; 2 | import { IoAdapter } from "@nestjs/platform-socket.io"; 3 | import { ServerOptions } from "socket.io"; 4 | import { createAdapter } from "@socket.io/redis-adapter"; 5 | import { Redis } from "ioredis"; 6 | import { RedisService } from "@liaoliaots/nestjs-redis"; 7 | 8 | export class RedisIoAdapter extends IoAdapter { 9 | private redisPublisher: Redis; 10 | private redisSubscriber: Redis; 11 | 12 | constructor( 13 | app: INestApplicationContext, 14 | private readonly redisService: RedisService, 15 | ) { 16 | super(app); 17 | this.redisPublisher = this.redisService.getOrThrow("publisher"); 18 | this.redisSubscriber = this.redisService.getOrThrow("consumer"); 19 | } 20 | 21 | createIOServer(port: number, options?: ServerOptions) { 22 | const server = super.createIOServer(port, options); 23 | const redisAdapter = createAdapter( 24 | this.redisPublisher, 25 | this.redisSubscriber, 26 | ); 27 | server.adapter(redisAdapter); 28 | 29 | return server; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/utils/redis-manager.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { RedisManager } from "./redis.manager"; 3 | 4 | @Module({ 5 | providers: [RedisManager], 6 | exports: [RedisManager], 7 | }) 8 | export class RedisManagerModule {} 9 | -------------------------------------------------------------------------------- /backend/src/utils/redis-timeout-scheduler.lua: -------------------------------------------------------------------------------- 1 | local streamKey = 'stream:' .. KEYS[1] 2 | local pelKey = streamKey .. ':pel' 3 | 4 | -- pel 확인 스크립트 5 | local message = redis.call('LINDEX', pelKey, 0) 6 | if message then 7 | if redis.call('EXISTS', pelKey .. message) ~= 1 then 8 | redis.call('RPOPLPUSH', pelKey, streamKey) 9 | redis.call("HSET", message, 'event_status', 'pending') 10 | end 11 | end 12 | 13 | -- EXECUTE COMMAND : redis-cli --eval ./test_key.lua users -------------------------------------------------------------------------------- /backend/src/utils/test/postgres-data.sql: -------------------------------------------------------------------------------- 1 | -- 테스트를 위한 임시 데이터를 PostgreSQL에 저장하는 스크립트입니다. 2 | -- 다음과 같이 실행할 수 있습니다. 3 | -- psql -U -d -f 4 | 5 | 6 | DO $$ 7 | DECLARE 8 | room_id UUID; 9 | user_id INTEGER; 10 | BEGIN 11 | room_id := gen_random_uuid(); 12 | 13 | INSERT INTO "bet_rooms" (id, "managerId", title, "defaultBetAmount", option1, option2, status, "joinUrl", timer) 14 | VALUES (room_id, (SELECT id FROM "users" LIMIT 1), 'test_bet_room_1', 100, 'option1', 'option2', 'active', 'testurl', 1); 15 | 16 | FOR i IN 1..500 LOOP 17 | INSERT INTO "users" (email, nickname, password, duck) 18 | VALUES ('test_' || i || '@email.com', 'test_nickname_' || i, 'test-pw', 300) 19 | RETURNING id INTO user_id; 20 | 21 | INSERT INTO "bets" ("betRoomId", "betAmount", "settledAmount", status, "selectedOption", "userId") 22 | VALUES (room_id, 100, 100, 'settled', 'option1', user_id); 23 | END LOOP; 24 | END $$; 25 | 26 | -------------------------------------------------------------------------------- /backend/src/utils/test/redis-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 테스트를 위한 임시 데이터를 Redis에 저장하는 스크립트입니다. 3 | 4 | room_id=2bbf4e10-af77-4714-bdcd-ac33f0dd9f76 5 | 6 | redis-cli SET "room:$room_id:creator" "user:2" 7 | redis-cli SET "room:$room_id:status" "active" 8 | redis-cli HSET "room:$room_id:option1" participants 100 currentBets 30000 9 | redis-cli HSET "room:$room_id:option2" participants 100 currentBets 60000 10 | 11 | for i in {1..500} 12 | do 13 | if ! redis-cli SADD "room:$room_id:userlist" "room:$room_id:user:$i"; then 14 | echo "Failed to set data for user:$i" >&2 15 | exit 1 16 | fi 17 | 18 | if ! redis-cli HSET "user:$i" nickname "test_nickname_$i" role "user" duck 300; then 19 | echo "Failed to set data for user:$i" >&2 20 | exit 1 21 | fi 22 | 23 | if ! redis-cli HSET "room:$room_id:user:$i" nickname "test_nickname_$i" owner 0 role user betAmount 100 selectedOption option1; then 24 | echo "Failed to push data to list room:$room_id:user:$i" >&2 25 | exit 1 26 | fi 27 | done 28 | 29 | echo "Test data has been saved to Redis." 30 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@shared/*": ["../shared/*"] 15 | }, 16 | "incremental": true, 17 | "skipLibCheck": true, 18 | "strictNullChecks": false, 19 | "noImplicitAny": false, 20 | "strictBindCallApply": false, 21 | "forceConsistentCasingInFileNames": false, 22 | "noFallthroughCasesInSwitch": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettier from "eslint-config-prettier"; 4 | import globals from "globals"; 5 | 6 | export default [ 7 | { 8 | files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], 9 | ignores: [ 10 | "**/node_modules/**", 11 | "**/dist/**", 12 | "**/build/**", 13 | "coverage/**", 14 | "**/.next/**", 15 | ".husky/**/*.js", 16 | // frontend/dist 관련 모든 파일을 명확하게 무시 17 | "frontend/dist/**", 18 | "**/frontend/dist/**/*.*", 19 | // dist 디렉토리 아래의 모든 것을 재귀적으로 무시 20 | "dist/**/*", 21 | // assets 디렉토리도 명시적으로 무시 22 | "**/assets/**", 23 | ], 24 | }, 25 | { 26 | languageOptions: { 27 | globals: { 28 | ...globals.browser, 29 | ...globals.node, 30 | process: "readonly", 31 | }, 32 | }, 33 | }, 34 | { 35 | files: [".husky/**/*.js"], 36 | rules: { 37 | "@typescript-eslint/no-require-imports": "off", 38 | "@typescript-eslint/no-var-requires": "off", 39 | }, 40 | }, 41 | { 42 | rules: { 43 | "no-process-env": "off", 44 | "no-process-exit": "off", 45 | }, 46 | }, 47 | { 48 | files: ["main.js", "electron/**/*"], 49 | languageOptions: { 50 | globals: { 51 | ...globals.node, 52 | }, 53 | }, 54 | }, 55 | { 56 | files: ["src/**/*"], 57 | languageOptions: { 58 | globals: { 59 | ...globals.browser, 60 | }, 61 | }, 62 | }, 63 | pluginJs.configs.recommended, 64 | ...tseslint.configs.recommended, 65 | prettier, 66 | ]; 67 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env 27 | .env.production 28 | .env.*.local 29 | .env.*.production 30 | *.local 31 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | Electron 학습중 2 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ); 29 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 20 | 27 | 33 | 39 | 45 | Betting duck 46 | 47 | 48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/app/provider/LayoutProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type LayoutType = "default" | "wide"; 4 | 5 | interface LayoutContextType { 6 | layoutType: LayoutType; 7 | setLayoutType: (layoutType: LayoutType) => void; 8 | } 9 | 10 | type LayoutContextProviderProps = { 11 | children: React.ReactNode; 12 | }; 13 | 14 | const LayoutContext = React.createContext( 15 | undefined, 16 | ); 17 | 18 | function LayoutProvider({ children }: LayoutContextProviderProps) { 19 | const [layoutType, setLayoutType] = React.useState("default"); 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | 28 | export { LayoutContext, LayoutProvider }; 29 | -------------------------------------------------------------------------------- /frontend/src/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.glb" { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module "*.hdr" { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | // 다른 에셋 타입들도 필요하다면 추가할 수 있습니다 12 | declare module "*.gltf" { 13 | const content: string; 14 | export default content; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/assets/fonts/NanumSquareRound.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/fonts/NanumSquareRound.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/NanumSquareRoundB.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/fonts/NanumSquareRoundB.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/NanumSquareRoundEB.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/fonts/NanumSquareRoundEB.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/at-sign.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/create-bet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/login-id-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/login-password-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/login.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/timer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/images/emoticon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/emoticon.png -------------------------------------------------------------------------------- /frontend/src/assets/images/main-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/main-logo.png -------------------------------------------------------------------------------- /frontend/src/assets/images/pond.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/pond.png -------------------------------------------------------------------------------- /frontend/src/assets/images/user-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/user-plus.png -------------------------------------------------------------------------------- /frontend/src/assets/images/vote-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/vote-page.png -------------------------------------------------------------------------------- /frontend/src/assets/images/waiting-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/waiting-user.png -------------------------------------------------------------------------------- /frontend/src/assets/images/win_duckicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/win_duckicon.png -------------------------------------------------------------------------------- /frontend/src/assets/images/win_peoples.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/win_peoples.png -------------------------------------------------------------------------------- /frontend/src/assets/images/win_trophy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/images/win_trophy.png -------------------------------------------------------------------------------- /frontend/src/assets/models/betting-duck.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/models/betting-duck.glb -------------------------------------------------------------------------------- /frontend/src/assets/models/industrial_sunset_puresky_4k.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/assets/models/industrial_sunset_puresky_4k.hdr -------------------------------------------------------------------------------- /frontend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __SOCKET_URL__: string; 2 | declare const __APP_ENV__: string; 3 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page-admin/EndPredictButton.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from "@tanstack/react-router"; 2 | import { 3 | Dialog, 4 | DialogTrigger, 5 | DialogContent, 6 | } from "@/shared/components/Dialog"; 7 | 8 | function EndPredictButton() { 9 | const navigate = useNavigate(); 10 | const { roomId } = useParams({ from: "/betting_/$roomId/vote" }); 11 | 12 | const handleFinishClick = () => { 13 | navigate({ 14 | to: `/betting/${roomId}/vote/decide`, 15 | }); 16 | }; 17 | 18 | return ( 19 | 20 | 21 | 27 | 28 | 29 |
30 |

HI

31 |
32 |
33 |
34 | ); 35 | } 36 | 37 | export { EndPredictButton }; 38 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page-admin/model/types.ts: -------------------------------------------------------------------------------- 1 | export interface BettingRoom { 2 | title: string; 3 | timeRemaining: number; 4 | option1: { 5 | content: string; 6 | stats: { 7 | coinAmount: number; 8 | bettingRate: string; 9 | participant: number; 10 | percentage: number; 11 | }; 12 | }; 13 | option2: { 14 | content: string; 15 | stats: { 16 | coinAmount: number; 17 | bettingRate: string; 18 | participant: number; 19 | percentage: number; 20 | }; 21 | }; 22 | } 23 | 24 | export interface BettingStats { 25 | participants: number; 26 | totalAmount: number; 27 | multiplier: number; 28 | returnRate: number; 29 | } 30 | 31 | export interface FetchBetRoomInfoData { 32 | channel: { 33 | creator: string; 34 | status: "waiting" | "active" | "timeover" | "finished"; 35 | option1: { 36 | participants: string; 37 | currentBets: string; 38 | }; 39 | option2: { 40 | participants: string; 41 | currentBets: string; 42 | }; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/api/endBetroom.ts: -------------------------------------------------------------------------------- 1 | import { getBettingRoomInfo } from "./getBettingRoomInfo"; 2 | 3 | async function endBetRoom( 4 | betroomId: string, 5 | winningOption: "option1" | "option2", 6 | ) { 7 | const response = await fetch(`/api/betrooms/end/${betroomId}`, { 8 | method: "PATCH", 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | body: JSON.stringify({ winning_option: winningOption }), 13 | }); 14 | if (!response.ok) { 15 | throw new Error("베팅 룸을 종료하는데 실패했습니다."); 16 | } 17 | 18 | const bettingRoomInfo = await getBettingRoomInfo(betroomId); 19 | if (!bettingRoomInfo?.channel.isAdmin) { 20 | throw new Error("방장만 베팅 룸을 종료할 수 있습니다."); 21 | } 22 | } 23 | 24 | export { endBetRoom }; 25 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/api/getBettingRoomInfo.ts: -------------------------------------------------------------------------------- 1 | import { responseBetRoomInfo } from "@betting-duck/shared"; 2 | 3 | async function getBettingRoomInfo(roomId: string) { 4 | try { 5 | const response = await fetch(`/api/betrooms/${roomId}`); 6 | if (!response.ok) { 7 | return null; // 에러 대신 null 반환 8 | } 9 | 10 | const { data } = await response.json(); 11 | const result = responseBetRoomInfo.safeParse(data); 12 | if (!result.success) { 13 | return null; // 파싱 실패시에도 null 반환 14 | } 15 | return { 16 | ...result.data, 17 | isPlaceBet: false, 18 | placeBetAmount: 0, 19 | }; 20 | } catch { 21 | return null; // 네트워크 에러 등의 경우에도 null 반환 22 | } 23 | } 24 | 25 | export { getBettingRoomInfo }; 26 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/api/getUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { responseUserInfoSchema } from "@betting-duck/shared"; 2 | 3 | async function getUserInfo() { 4 | const response = await fetch("/api/users/userInfo"); 5 | if (!response.ok) { 6 | return null; 7 | } 8 | 9 | const { data } = await response.json(); 10 | const result = responseUserInfoSchema.safeParse(data); 11 | if (!result.success) { 12 | console.error(result.error); 13 | return null; 14 | } 15 | 16 | return data; 17 | } 18 | 19 | export { getUserInfo }; 20 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/hook/useBettingContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BettingContext } from "../provider/BettingProvider"; 3 | 4 | function useBettingContext() { 5 | const context = React.useContext(BettingContext); 6 | if (!context) { 7 | throw new Error("useContext must be used within a BettingProvider"); 8 | } 9 | return context; 10 | } 11 | 12 | export { useBettingContext }; 13 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/model/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const bettingRoomSchema = z.object({ 4 | channel: z.object({ 5 | creator: z.string(), 6 | status: z.enum(["waiting", "active", "timeover", "finished"]), 7 | option1: z.object({ 8 | participants: z.coerce.number().int().lte(Number.MAX_SAFE_INTEGER), 9 | currentBets: z.coerce.number().int().lte(Number.MAX_SAFE_INTEGER), 10 | }), 11 | option2: z.object({ 12 | participants: z.coerce.number().int().lte(Number.MAX_SAFE_INTEGER), 13 | currentBets: z.coerce.number().int().lte(Number.MAX_SAFE_INTEGER), 14 | }), 15 | }), 16 | }); 17 | 18 | const userBettingStatusSchema = z.object({ 19 | betAmount: z.coerce.number().int().min(0), 20 | selectedOption: z.union([ 21 | z.literal("option1"), 22 | z.literal("option2"), 23 | z.literal("none"), 24 | ]), 25 | }); 26 | 27 | export { bettingRoomSchema, userBettingStatusSchema }; 28 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/model/var.ts: -------------------------------------------------------------------------------- 1 | import { responseBetRoomInfo } from "@betting-duck/shared"; 2 | import { z } from "zod"; 3 | 4 | export const STORAGE_KEY = "betting_pool"; 5 | export const DEFAULT_BETTING_ROOM_INFO: z.infer = { 6 | message: "OK", 7 | channel: { 8 | id: "", 9 | title: "", 10 | creator: { 11 | id: 0, 12 | }, 13 | options: { 14 | option1: { 15 | name: "", 16 | }, 17 | option2: { 18 | name: "", 19 | }, 20 | }, 21 | status: "active", 22 | settings: { 23 | defaultBetAmount: 0, 24 | duration: 0, 25 | }, 26 | metadata: { 27 | createdAt: "", 28 | startAt: "", 29 | endAt: "", 30 | }, 31 | urls: { 32 | invite: "", 33 | }, 34 | isAdmin: false, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/ui/BettingContainer.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/misc"; 2 | import { useSocketIO } from "@/shared/hooks/useSocketIo"; 3 | import { TotalBettingDisplay } from "./TotalBettingDisplay"; 4 | import { BettingHeader } from "./BettingHeader"; 5 | import { BettingInput } from "./BettingInput"; 6 | import { BettingFooter } from "./BettingFooter"; 7 | import { BettingRoomInfo } from "@/shared/types"; 8 | 9 | function BettingContainer({ 10 | socket, 11 | bettingRoomInfo, 12 | }: { 13 | socket: ReturnType; 14 | bettingRoomInfo: BettingRoomInfo; 15 | }) { 16 | const { channel } = bettingRoomInfo; 17 | 18 | return ( 19 |
25 |
26 | 27 | 28 |
29 | 34 | 39 |
40 | 41 |
42 |
43 | ); 44 | } 45 | 46 | export { BettingContainer }; 47 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/ui/BettingFooter.tsx: -------------------------------------------------------------------------------- 1 | import { DuckCoinIcon } from "@/shared/icons"; 2 | import React from "react"; 3 | import { useSuspenseQuery } from "@tanstack/react-query"; 4 | import { authQueries } from "@/shared/lib/auth/authQuery"; 5 | import { BettingRoomInfo } from "@/shared/types"; 6 | 7 | function BettingFooter({ 8 | bettingRoomInfo, 9 | }: { 10 | bettingRoomInfo: BettingRoomInfo; 11 | }) { 12 | const { data: authData } = useSuspenseQuery({ 13 | queryKey: authQueries.queryKey, 14 | queryFn: authQueries.queryFn, 15 | }); 16 | const duckCoin = authData.userInfo.duck; 17 | 18 | const remainingAmount = React.useMemo( 19 | () => duckCoin - bettingRoomInfo.placeBetAmount, 20 | [duckCoin, bettingRoomInfo.placeBetAmount], 21 | ); 22 | 23 | return ( 24 |
25 |
26 | 베팅 금액:{" "} 27 | 28 | 29 | {bettingRoomInfo.placeBetAmount} 30 | 31 |
32 |
33 | 소유 금액:{" "} 34 | 35 | 36 | {remainingAmount} 37 | 38 |
39 |
40 | ); 41 | } 42 | 43 | export { BettingFooter }; 44 | -------------------------------------------------------------------------------- /frontend/src/features/betting-page/ui/BettingHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSocketIO } from "@/shared/hooks/useSocketIo"; 3 | 4 | function BettingHeader({ 5 | socket, 6 | content, 7 | }: { 8 | socket: ReturnType; 9 | content: string; 10 | }) { 11 | const [isBettingEnd, setIsBettingEnd] = React.useState(false); 12 | 13 | React.useEffect(() => { 14 | socket.on("timeover", () => setIsBettingEnd(true)); 15 | 16 | return () => { 17 | socket.off("timeover"); 18 | }; 19 | }, [setIsBettingEnd, socket]); 20 | 21 | return ( 22 |
23 |

24 | 베팅 주제 25 |

26 |

27 | {isBettingEnd ? "투표 시간이 종료 되었습니다!" : content} 28 |

29 |

30 | {isBettingEnd 31 | ? "방장이 결과를 결정 할 때까지 기다려 주세요!" 32 | : "투표가 진행 중입니다! 제한 시간 내에 둘 중 하나 베팅을 해주세요!"} 33 |

34 |
35 | ); 36 | } 37 | 38 | export { BettingHeader }; 39 | -------------------------------------------------------------------------------- /frontend/src/features/chat/hook/useChat.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ChatContext } from "../provider/ChatProvider"; 3 | 4 | function useChat() { 5 | const context = React.useContext(ChatContext); 6 | if (!context) { 7 | throw new Error("useChat must be used within a ChatProvider"); 8 | } 9 | return context; 10 | } 11 | 12 | export { useChat }; 13 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ChartTitle } from "./ui/ChatTitle"; 3 | import { PredictButton } from "./ui/PredictButton"; 4 | import { channelSchema } from "@betting-duck/shared"; 5 | import { z } from "zod"; 6 | import { BettingRoomInfo } from "@/shared/types"; 7 | 8 | function bettingRoomTypeGuard( 9 | data: unknown, 10 | ): data is z.infer { 11 | return channelSchema.safeParse(data).success; 12 | } 13 | 14 | function ChatHeader({ bettingRoomInfo }: { bettingRoomInfo: BettingRoomInfo }) { 15 | const { channel } = bettingRoomInfo; 16 | if (!bettingRoomTypeGuard(channel)) { 17 | throw new Error("bettingRoomInfo가 channelSchema에 맞지 않습니다."); 18 | } 19 | 20 | return ( 21 | 22 |
23 |
24 | 25 | 26 |
27 |
31 |
32 | 33 | ); 34 | } 35 | 36 | export { ChatHeader }; 37 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatHeader/ui/ChatTitle.tsx: -------------------------------------------------------------------------------- 1 | function ChartTitle({ title }: { title: string }) { 2 | return ( 3 |
4 | 5 | 승부 예측 주제 6 | 7 |

{title}

8 |
9 | ); 10 | } 11 | 12 | export { ChartTitle }; 13 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatHeader/ui/PredictButton.tsx: -------------------------------------------------------------------------------- 1 | import { BettingPage } from "@/features/betting-page"; 2 | import { useChat } from "@/features/chat/hook/useChat"; 3 | import { 4 | Dialog, 5 | DialogTrigger, 6 | DialogContent, 7 | } from "@/shared/components/Dialog"; 8 | import { useNavigate, useParams } from "@tanstack/react-router"; 9 | import { STORAGE_KEY } from "@/features/betting-page/model/var"; 10 | import { useUserContext } from "@/shared/hooks/useUserContext"; 11 | 12 | function PredictButton() { 13 | const { socket } = useChat(); 14 | const { setUserInfo } = useUserContext(); 15 | const { roomId } = useParams({ from: "/betting_/$roomId/vote" }); 16 | const navigate = useNavigate(); 17 | 18 | return ( 19 | 20 | { 23 | socket.emit("leaveRoom", { roomId }); 24 | setUserInfo({ role: "user", roomId: undefined }); 25 | sessionStorage.removeItem(STORAGE_KEY); 26 | navigate({ to: "/my-page" }); 27 | }} 28 | > 29 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | export { PredictButton }; 41 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatHeader/ui/PredictionStatus.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web14-betting-duck/b41fd4c7ce4e763c3bdc66ecbea9bd05ff8427f5/frontend/src/features/chat/ui/ChatHeader/ui/PredictionStatus.tsx -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EmoticomButton } from "./ui/EmoticonButton"; 3 | import { InputBar } from "./ui/InputBar"; 4 | import { VoteButton } from "./ui/VoteButton"; 5 | 6 | function ChatInput({ nickname }: { nickname: string }) { 7 | return ( 8 | 9 |
10 |
14 | 15 | 16 | 17 |
18 | 19 | ); 20 | } 21 | 22 | export { ChatInput }; 23 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatInput/ui/EmoticonButton.tsx: -------------------------------------------------------------------------------- 1 | import useToggle from "@/shared/hooks/useTrigger"; 2 | import styles from "./style.module.css"; 3 | import emoticonImage from "@assets/images/emoticon.png"; 4 | 5 | function EmoticomButton() { 6 | const [isClick, toggleClick] = useToggle(); 7 | 8 | return ( 9 |
10 | 27 |
28 | {isClick ? ( 29 |
HIHI
30 | ) : ( 31 |
emoticon
32 | )} 33 |
{" "} 34 |
35 | ); 36 | } 37 | 38 | export { EmoticomButton }; 39 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatInput/ui/VoteButton.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./style.module.css"; 2 | import votePageImage from "@assets/images/vote-page.png"; 3 | 4 | function VoteButton() { 5 | return ( 6 |
7 | 17 |
18 |
vote page
19 |
20 |
21 | ); 22 | } 23 | 24 | export { VoteButton }; 25 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatInput/ui/style.module.css: -------------------------------------------------------------------------------- 1 | .vote-page-button { 2 | position: relative; 3 | display: grid; 4 | place-items: center; 5 | } 6 | 7 | .tooltip { 8 | position: absolute; 9 | white-space: nowrap; 10 | pointer-events: none; 11 | 12 | top: -49px; 13 | left: -33px; 14 | padding: 4px 8px; 15 | padding-bottom: 12px; 16 | border-radius: 4px; 17 | 18 | opacity: 0; 19 | visibility: hidden; 20 | background: #354357; 21 | color: #e6edf8; 22 | 23 | clip-path: polygon( 24 | 0% 0%, 25 | 100% 0%, 26 | 100% calc(100% - 10px), 27 | calc(50% + 10px) calc(100% - 10px), 28 | 50% 100%, 29 | calc(50% - 10px) calc(100% - 10px), 30 | 0% calc(100% - 10px) 31 | ); 32 | 33 | transition: 34 | opacity 200ms ease-in-out, 35 | visibility 200ms ease-in-out; 36 | z-index: 1; 37 | } 38 | 39 | .vote-page-button:hover .tooltip { 40 | opacity: 1; 41 | visibility: visible; 42 | } 43 | 44 | .vote-page { 45 | left: -35px; 46 | } 47 | 48 | .input-placeholder { 49 | display: flex; 50 | 51 | position: absolute; 52 | top: calc(50% - 0.75rem); 53 | 54 | flex-direction: row; 55 | font-size: 0.75rem; 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/features/chat/ui/ChatMessages/ui/Message.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface MessageProps { 4 | sender: { 5 | nickname: string; 6 | }; 7 | message: string; 8 | color: string; 9 | radius: string; 10 | } 11 | 12 | const Message: React.FC = ( 13 | { sender, message, color, radius }: MessageProps, 14 | index: number, 15 | ) => { 16 | const firstMessageRegexp = /^(.+)님이 입장하셨습니다\.$/; 17 | const firstMessage = firstMessageRegexp.exec(message); 18 | if (firstMessage) { 19 | return ( 20 |
21 |
24 |
25 | 28 | {firstMessage[1]} 29 | 30 | 31 | 님이 입장하셨습니다. 32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | 39 | return ( 40 |
41 |
44 |
45 | 46 | {sender.nickname} 47 | 48 | {message} 49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | export default Message; 56 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/index.ts: -------------------------------------------------------------------------------- 1 | export { CreateVotePage } from "./ui/CreateVotePage"; 2 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/api.ts: -------------------------------------------------------------------------------- 1 | import { PredictionRequest } from "./types"; 2 | 3 | async function createPrediction(data: PredictionRequest) { 4 | const response = await fetch("/api/betrooms", { 5 | method: "POST", 6 | headers: { "Content-Type": "application/json" }, 7 | body: JSON.stringify(data), 8 | }); 9 | 10 | if (!response.ok) { 11 | throw new Error("API 요청 실패"); 12 | } 13 | 14 | return response.json(); 15 | } 16 | 17 | export { createPrediction }; 18 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/helpers/formatData.ts: -------------------------------------------------------------------------------- 1 | import { PredictionData, PredictionRequest } from "../types"; 2 | 3 | export function formatPredictionData({ 4 | title, 5 | winCase, 6 | loseCase, 7 | timer, 8 | coin, 9 | }: PredictionData): PredictionRequest { 10 | return { 11 | channel: { 12 | title: title, 13 | options: { 14 | option1: winCase, 15 | option2: loseCase, 16 | }, 17 | settings: { 18 | duration: timer * 60, 19 | defaultBetAmount: coin, 20 | }, 21 | }, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/types.ts: -------------------------------------------------------------------------------- 1 | export interface PredictionData { 2 | title: string; 3 | winCase: string; 4 | loseCase: string; 5 | timer: number; 6 | coin: number; 7 | } 8 | 9 | export interface PredictionRequest { 10 | channel: { 11 | title: string; 12 | options: { 13 | option1: string; 14 | option2: string; 15 | }; 16 | settings: { 17 | duration: number; 18 | defaultBetAmount: number; 19 | }; 20 | }; 21 | } 22 | 23 | export type ValidationEventDetail = { 24 | name: "title" | "winCase" | "loseCase" | "timer" | "coin"; 25 | isValid: boolean; 26 | }; 27 | 28 | export type ValidationEvent = CustomEvent; 29 | 30 | declare global { 31 | interface WindowEventMap { 32 | "form-validation": ValidationEvent; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/useCaseInput.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useCaseInput(winIntialValue = "", loseInitialValue = "") { 4 | const [winValue, setWinValue] = useState(winIntialValue); 5 | const [loseValue, setLoseValue] = useState(loseInitialValue); 6 | 7 | useEffect(() => { 8 | window.dispatchEvent( 9 | new CustomEvent("form-validation", { 10 | detail: { 11 | name: "winCase", 12 | isValid: winValue.trim().length > 0, 13 | }, 14 | }), 15 | ); 16 | }, [winValue]); 17 | 18 | useEffect(() => { 19 | window.dispatchEvent( 20 | new CustomEvent("form-validation", { 21 | detail: { 22 | name: "loseCase", 23 | isValid: loseValue.trim().length > 0, 24 | }, 25 | }), 26 | ); 27 | }, [loseValue]); 28 | 29 | return { winValue, setWinValue, loseValue, setLoseValue }; 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/useCoinInput.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useCoinInput(initialValue = 100) { 4 | const [value, setValue] = useState(initialValue); 5 | 6 | useEffect(() => { 7 | window.dispatchEvent( 8 | new CustomEvent("form-validation", { 9 | detail: { 10 | name: "coin", 11 | isValid: value >= 100, 12 | }, 13 | }), 14 | ); 15 | }, [value]); 16 | 17 | const incrementValue = () => setValue((prev) => prev + 100); 18 | const decrementValue = () => setValue((prev) => Math.max(100, prev - 100)); 19 | 20 | return { value, setValue, incrementValue, decrementValue }; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/useTimer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useTimer(initialValue = 1) { 4 | const [value, setValue] = useState(initialValue); 5 | 6 | useEffect(() => { 7 | window.dispatchEvent( 8 | new CustomEvent("form-validation", { 9 | detail: { 10 | name: "timer", 11 | isValid: value >= 1, 12 | }, 13 | }), 14 | ); 15 | }, [value]); 16 | 17 | const incrementValue = () => setValue((prev) => prev + 1); 18 | const decrementValue = () => setValue((prev) => Math.max(1, prev - 1)); 19 | 20 | return { value, incrementValue, decrementValue }; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/useTitleInput.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useTitleInput(initialValue = "") { 4 | const [value, setValue] = useState(initialValue); 5 | 6 | useEffect(() => { 7 | window.dispatchEvent( 8 | new CustomEvent("form-validation", { 9 | detail: { 10 | name: "title", 11 | isValid: value.trim().length > 0, 12 | }, 13 | }), 14 | ); 15 | }, [value]); 16 | 17 | return { value, setValue }; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/model/useValidation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { ValidationEvent } from "./types"; 3 | 4 | export function useValidation() { 5 | const [validInputs, setValidInputs] = useState(new Set()); 6 | 7 | const requiredInputs = useMemo( 8 | () => new Set(["title", "winCase", "loseCase", "timer", "coin"]), 9 | [], 10 | ); 11 | 12 | useEffect(() => { 13 | const initialValidInputs = new Set(); 14 | 15 | initialValidInputs.add("timer"); 16 | initialValidInputs.add("coin"); 17 | 18 | setValidInputs(initialValidInputs); 19 | }, []); 20 | 21 | useEffect(() => { 22 | const handleValidation = (e: ValidationEvent) => { 23 | const { name, isValid } = e.detail; 24 | 25 | setValidInputs((prev) => { 26 | const next = new Set(prev); 27 | if (isValid) { 28 | next.add(name); 29 | } else { 30 | next.delete(name); 31 | } 32 | return new Set(next); 33 | }); 34 | }; 35 | 36 | window.addEventListener("form-validation", handleValidation); 37 | return () => 38 | window.removeEventListener("form-validation", handleValidation); 39 | }, []); 40 | 41 | const isFormValid = useMemo(() => { 42 | if (requiredInputs.size !== validInputs.size) { 43 | return false; 44 | } 45 | return [...requiredInputs].every((input) => validInputs.has(input)); 46 | }, [validInputs, requiredInputs]); 47 | 48 | return { isFormValid }; 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/ui/components/CaseInputs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { InputField } from "../../../../shared/components/input/InputField"; 3 | import { DuckIcon } from "@/shared/icons"; 4 | import { useCaseInput } from "@/features/create-vote/model/useCaseInput"; 5 | 6 | const CaseInputs = React.memo( 7 | ({ 8 | winIntialValue = "", 9 | loseInitialValue = "", 10 | }: { 11 | winIntialValue?: string; 12 | loseInitialValue?: string; 13 | }) => { 14 | const { winValue, setWinValue, loseValue, setLoseValue } = useCaseInput( 15 | winIntialValue, 16 | loseInitialValue, 17 | ); 18 | 19 | return ( 20 |
21 | setWinValue(e.target.value)} 27 | > 28 | 29 | 30 |
31 | setLoseValue(e.target.value)} 37 | > 38 | 39 | 40 |
41 | ); 42 | }, 43 | ); 44 | 45 | export { CaseInputs }; 46 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/ui/components/CoinInput.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowDownIcon, ArrowUpIcon, DuckCoinIcon } from "@/shared/icons"; 2 | import React from "react"; 3 | import { useCoinInput } from "@/features/create-vote/model/useCoinInput"; 4 | 5 | const CoinInput = React.memo( 6 | ({ initialValue = 100 }: { initialValue?: number }) => { 7 | const { value, incrementValue, decrementValue } = 8 | useCoinInput(initialValue); 9 | 10 | return ( 11 |
12 | 21 |
22 | 23 | 최소 금액 설정 24 |
25 | {value} 26 |
27 |
28 | 31 |
32 | 35 |
36 |
37 | ); 38 | }, 39 | ); 40 | 41 | export { CoinInput }; 42 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/ui/components/TitleInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { InputField } from "../../../../shared/components/input/InputField"; 3 | import { TextIcon } from "@/shared/icons"; 4 | import { useTitleInput } from "@/features/create-vote/model/useTitleInput"; 5 | 6 | const TitleInput = React.memo( 7 | ({ initialValue = "" }: { initialValue?: string }) => { 8 | const { value, setValue } = useTitleInput(initialValue); 9 | 10 | return ( 11 |
12 | setValue(e.target.value)} 18 | min={10} 19 | > 20 | 21 | 22 |
23 | ); 24 | }, 25 | ); 26 | 27 | export { TitleInput }; 28 | -------------------------------------------------------------------------------- /frontend/src/features/create-vote/ui/components/index.ts: -------------------------------------------------------------------------------- 1 | export { CaseInputs } from "./CaseInputs"; 2 | export { CoinInput } from "./CoinInput"; 3 | export { Timer } from "./Timer"; 4 | export { TitleInput } from "./TitleInput"; 5 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/index.ts: -------------------------------------------------------------------------------- 1 | export { LoginPage } from "./ui/LoginPage"; 2 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/model/api.ts: -------------------------------------------------------------------------------- 1 | import { GuestLoginRequest, LoginRequest, SignupRequest } from "./types"; 2 | 3 | export const login = async (data: LoginRequest) => { 4 | const response = await fetch("/api/users/signin", { 5 | method: "POST", 6 | headers: { "Content-Type": "application/json" }, 7 | body: JSON.stringify(data), 8 | }); 9 | 10 | const result = await response.json(); 11 | 12 | if (response.ok) { 13 | return result; 14 | } 15 | 16 | if (response.status === 401) { 17 | throw Error("아이디 혹은 비밀번호가 잘못되었습니다. 다시 입력해주세요."); 18 | } else { 19 | throw Error("오류가 발생했습니다. 다시 시도해주세요."); 20 | } 21 | }; 22 | 23 | export const signup = async (data: SignupRequest) => { 24 | const response = await fetch("/api/users/signup", { 25 | method: "POST", 26 | headers: { "Content-Type": "application/json" }, 27 | body: JSON.stringify(data), 28 | }); 29 | 30 | const result = await response.json(); 31 | let resultMessage = result.data.message; 32 | if (response.ok) { 33 | return resultMessage; 34 | } 35 | 36 | if (response.status === 400 || response.status === 409) { 37 | resultMessage = resultMessage.message; 38 | throw Error(resultMessage); 39 | } else { 40 | resultMessage = resultMessage.message; 41 | throw Error(resultMessage); 42 | } 43 | }; 44 | 45 | export const guestlogin = async (data: GuestLoginRequest) => { 46 | const response = await fetch("/api/users/guestsignin", { 47 | method: "POST", 48 | headers: { "Content-Type": "application/json" }, 49 | body: JSON.stringify(data), 50 | }); 51 | 52 | const result = await response.json(); 53 | 54 | let resultMessage = result.data.message; 55 | if (response.ok) { 56 | return resultMessage; 57 | } 58 | 59 | if (response.status === 409) { 60 | resultMessage = resultMessage.message; 61 | throw Error(resultMessage); 62 | } else { 63 | resultMessage = resultMessage.message; 64 | throw Error(resultMessage); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/model/store.ts: -------------------------------------------------------------------------------- 1 | import { GuestLoginRequest, LoginRequest, SignupRequest } from "./types"; 2 | import { guestlogin, login, signup } from "./api"; 3 | import { useState } from "react"; 4 | 5 | function useAuthStore() { 6 | const [error, setError] = useState(null); 7 | 8 | const handleLogin = async (data: LoginRequest) => { 9 | setError(null); 10 | try { 11 | const response = await login(data); 12 | return { success: true, data: response }; 13 | } catch (err) { 14 | if (err instanceof Error) { 15 | setError(err.message); 16 | return { success: false, error: err.message }; 17 | } 18 | } 19 | return { success: false, error: "알 수 없는 오류가 발생했습니다." }; 20 | }; 21 | 22 | const handleSignup = async (data: SignupRequest) => { 23 | setError(null); 24 | try { 25 | const response = await signup(data); 26 | return { success: true, data: response }; 27 | } catch (err) { 28 | if (err instanceof Error) { 29 | setError(err.message); 30 | return { success: false, error: err.message }; 31 | } 32 | } 33 | return { success: false, error: "알 수 없는 오류가 발생했습니다." }; 34 | }; 35 | 36 | const handleGuestLogin = async (data: GuestLoginRequest) => { 37 | setError(null); 38 | try { 39 | const response = await guestlogin(data); 40 | return { success: true, data: response }; 41 | } catch (err) { 42 | if (err instanceof Error) { 43 | setError(err.message); 44 | return { success: false, error: err.message }; 45 | } 46 | } 47 | return { success: false, error: "알 수 없는 오류가 발생했습니다." }; 48 | }; 49 | 50 | return { error, handleLogin, handleSignup, handleGuestLogin }; 51 | } 52 | 53 | export { useAuthStore }; 54 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/model/types.ts: -------------------------------------------------------------------------------- 1 | export interface LoginRequest { 2 | email: string; 3 | password: string; 4 | } 5 | 6 | export interface RegisterRequest { 7 | email: string; 8 | nickname: string; 9 | password: string; 10 | } 11 | 12 | export interface GuestLoginRequest { 13 | nickname: string; 14 | } 15 | 16 | export interface LoginRequest { 17 | email: string; 18 | password: string; 19 | } 20 | 21 | export interface SignupRequest { 22 | email: string; 23 | nickname: string; 24 | password: string; 25 | } 26 | 27 | export interface GuestLoginRequest { 28 | nickname: string; 29 | } 30 | 31 | export interface LoginResponse { 32 | status: number; 33 | data: { 34 | message: string; 35 | accessToken: string; 36 | nickname: string; 37 | role: string; 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/model/validation.ts: -------------------------------------------------------------------------------- 1 | export const validateEmail = (email: string): boolean => 2 | /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length >= 6; 3 | 4 | export const validatePassword = (password: string): boolean => 5 | /^(?=.*[a-z])(?=.*\d).{6,}$/.test(password); 6 | 7 | export const validateNickname = (nickname: string): boolean => 8 | nickname.trim().length >= 1 && nickname.trim().length <= 10; 9 | 10 | export const validateConfirmPassword = ( 11 | password: string, 12 | confirmPassword: string, 13 | ): boolean => password.trim() === confirmPassword.trim(); 14 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/ui/components/TabButton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/misc"; 2 | 3 | interface TabButtonProps { 4 | label: string; 5 | tab: "login" | "register" | "guest"; 6 | activeTab: "login" | "register" | "guest"; 7 | onClick: (tab: "login" | "register" | "guest") => void; 8 | } 9 | 10 | function TabButton({ label, tab, activeTab, onClick }: TabButtonProps) { 11 | return ( 12 | 21 | ); 22 | } 23 | 24 | export { TabButton }; 25 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/ui/components/Warning.tsx: -------------------------------------------------------------------------------- 1 | function Warning({ message }: { message: string }) { 2 | return
* {message}
; 3 | } 4 | 5 | export { Warning }; 6 | -------------------------------------------------------------------------------- /frontend/src/features/login-page/ui/components/index.ts: -------------------------------------------------------------------------------- 1 | export { GuestLoginForm } from "./GuestLoginForm"; 2 | export { LoginForm } from "./LoginForm"; 3 | export { RegisterForm } from "./RegisterForm"; 4 | export { TabButton } from "./TabButton"; 5 | export { Warning } from "./Warning"; 6 | -------------------------------------------------------------------------------- /frontend/src/features/my-page/error/index.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from "@/shared/components/Image"; 2 | import pondImage from "@/assets/images/pond.png"; 3 | import { DuckCoinIcon } from "@/shared/icons"; 4 | 5 | function ErrorMyPage() { 6 | return ( 7 |
8 |
9 |

마이 페이지

10 |

오리를 구매해서 페이지를 꾸며보세요

11 |
12 |
13 | 14 | {0} 15 |
16 |
17 | Pond 18 |
19 |
20 | 24 | 27 |
28 |
29 | ); 30 | } 31 | 32 | export { ErrorMyPage }; 33 | -------------------------------------------------------------------------------- /frontend/src/features/my-page/ui/FallingDuck.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { useBox } from "@react-three/cannon"; 3 | import { Gltf } from "@react-three/drei"; 4 | import duckModel from "@/assets/models/betting-duck.glb"; 5 | 6 | function FallingDuck() { 7 | // 약간의 랜덤 오프셋 추가 (x축과 z축에 대해) 8 | const randomX = (Math.random() - 0.5) * 2; // -1 ~ 1 사이의 랜덤값 9 | const randomZ = (Math.random() - 0.5) * 2; // -1 ~ 1 사이의 랜덤값 10 | 11 | const [ref] = useBox(() => ({ 12 | mass: 1, 13 | position: [randomX, 5, randomZ], // 초기 위치에 랜덤값 적용 14 | rotation: [ 15 | Math.random() * 0.2, // 약간의 랜덤 회전도 추가 16 | Math.random() * 0.2, 17 | Math.random() * 0.2, 18 | ], 19 | linearDamping: 0.4, 20 | angularDamping: 0.4, 21 | material: { 22 | friction: 0.3, 23 | restitution: 0.3, 24 | }, 25 | })); 26 | 27 | return ( 28 | } 30 | castShadow 31 | receiveShadow 32 | src={duckModel} 33 | /> 34 | ); 35 | } 36 | 37 | export { FallingDuck }; 38 | -------------------------------------------------------------------------------- /frontend/src/features/predict-detail/model/api.ts: -------------------------------------------------------------------------------- 1 | import { bettinResultSchema, type BetResultResponseType } from "./schema"; 2 | 3 | interface BetResultResponseSuccess { 4 | status: 200; 5 | data: { 6 | option_1_total_bet: string; 7 | option_2_total_bet: string; 8 | option_1_total_participants: string; 9 | option_2_total_participants: string; 10 | winning_option: "option1" | "option2"; 11 | message: string; 12 | }; 13 | } 14 | 15 | interface BetResultResponseError { 16 | status: 404; 17 | data: { 18 | message: string; 19 | }; 20 | } 21 | 22 | type BetResultResponse = BetResultResponseSuccess | BetResultResponseError; 23 | 24 | export async function getBetResults( 25 | betRoomId: string, 26 | ): Promise { 27 | try { 28 | const response = await fetch(`/api/betresults/${betRoomId}`, { 29 | method: "GET", 30 | headers: { 31 | "Content-Type": "application/json", 32 | }, 33 | }); 34 | if (!response.ok) { 35 | throw new Error("베팅 결과를 가져오는데 실패했습니다."); 36 | } 37 | 38 | const responseData: BetResultResponse = await response.json(); 39 | const parsedData = bettinResultSchema.safeParse(responseData.data); 40 | if (!parsedData.success) { 41 | console.error("Invalid response data:", parsedData.error); 42 | throw new Error("베팅 결과를 가져오는데 실패했습니다."); 43 | } 44 | return parsedData.data; 45 | } catch (error) { 46 | console.error("요청 실패:", error); 47 | throw new Error("Failed to fetch bet results. Please try again."); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/features/predict-detail/model/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const bettinResultSchema = z.object({ 4 | option_1_total_bet: z.coerce.number().int().min(0), 5 | option_2_total_bet: z.coerce.number().int().min(0), 6 | option_1_total_participants: z.coerce.number().int().min(0), 7 | option_2_total_participants: z.coerce.number().int().min(0), 8 | winning_option: z.union([z.literal("option1"), z.literal("option2")]), 9 | message: z.string(), 10 | }); 11 | 12 | export type BetResultResponseType = z.infer; 13 | -------------------------------------------------------------------------------- /frontend/src/features/predict-detail/ui/AdminBettingResult.tsx: -------------------------------------------------------------------------------- 1 | interface BettingResultAdminProps { 2 | winningOption: "option1" | "option2"; 3 | winner: string; 4 | winnerCount: number; 5 | } 6 | function AdminBettingResult({ 7 | winningOption, 8 | winner, 9 | winnerCount, 10 | }: BettingResultAdminProps) { 11 | return ( 12 |
13 |
14 |

15 | 베팅 결과 16 |

17 |
18 |
19 |
20 |
승리 팀
21 | {winningOption === "option1" ? ( 22 | {winner} 23 | ) : ( 24 | {winner} 25 | )} 26 |
27 |
28 | 승리 팀이 얻은 포인트 29 | {winnerCount * 100} 30 |
31 |
32 | 승리 인원 33 | {winnerCount}명 34 |
35 |
36 |
37 | ); 38 | } 39 | 40 | export { AdminBettingResult }; 41 | -------------------------------------------------------------------------------- /frontend/src/features/predict-detail/ui/GuestFooter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image } from "@/shared/components/Image"; 3 | import userPlusImage from "@/assets/images/user-plus.png"; 4 | import { useNavigate } from "@tanstack/react-router"; 5 | 6 | function GuestFooter() { 7 | const navigate = useNavigate(); 8 | 9 | const handleJoinClick = () => { 10 | navigate({ to: "/login" }); 11 | }; 12 | 13 | return ( 14 | 15 | 27 | 28 | 회원가입을 하시면 얻은 코인을 모두 얻을 수 있고 마이페이지를 이용하실 수 29 | 있습니다! 30 | 31 | 32 | ); 33 | } 34 | 35 | export { GuestFooter }; 36 | -------------------------------------------------------------------------------- /frontend/src/features/predict-detail/ui/UserFooter.tsx: -------------------------------------------------------------------------------- 1 | import { useUserContext } from "@/shared/hooks/useUserContext"; 2 | import { useNavigate } from "@tanstack/react-router"; 3 | 4 | function UserFooter() { 5 | const navigate = useNavigate(); 6 | const { setUserInfo } = useUserContext(); 7 | 8 | const handleMyPageClick = () => { 9 | setUserInfo({ roomId: "", role: "user" }); 10 | window.location.href = "/my-page"; 11 | }; 12 | 13 | const handleCreateClick = () => { 14 | setUserInfo({ roomId: "", role: "user" }); 15 | navigate({ to: "/create-vote" }); 16 | }; 17 | 18 | return ( 19 |
20 | 26 | 32 |
33 | ); 34 | } 35 | 36 | export { UserFooter }; 37 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/error/AccessError.ts: -------------------------------------------------------------------------------- 1 | class AccessError extends Error { 2 | constructor( 3 | message: string, 4 | public readonly code: string = "ACCESS_DENIED", 5 | public readonly status: number = 403, 6 | public readonly target?: string, 7 | public readonly details?: Record, 8 | ) { 9 | super(message); 10 | this.name = "AccessError"; 11 | Object.setPrototypeOf(this, AccessError.prototype); 12 | if (Error.captureStackTrace) { 13 | Error.captureStackTrace(this, AccessError); 14 | } 15 | this.timestamp = new Date(); 16 | } 17 | 18 | readonly timestamp: Date; 19 | static unauthorized( 20 | message = "Unauthorized access", 21 | details?: Record, 22 | ): AccessError { 23 | return new AccessError(message, "UNAUTHORIZED", 401, undefined, details); 24 | } 25 | 26 | static forbidden( 27 | message = "Access forbidden", 28 | details?: Record, 29 | ): AccessError { 30 | return new AccessError(message, "FORBIDDEN", 403, undefined, details); 31 | } 32 | 33 | static insufficientPermissions( 34 | resource: string, 35 | details?: Record, 36 | ): AccessError { 37 | return new AccessError( 38 | `Insufficient permissions to access ${resource}`, 39 | "INSUFFICIENT_PERMISSIONS", 40 | 403, 41 | resource, 42 | details, 43 | ); 44 | } 45 | } 46 | 47 | export { AccessError }; 48 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/hooks/use-waiting-context.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { WaitingRoomContext } from "../provider/WaitingRoomProvider"; 3 | 4 | function useWaitingContext() { 5 | const context = React.useContext(WaitingRoomContext); 6 | 7 | if (!context) { 8 | throw new Error( 9 | "useWaitingContext 는 반드시 WaitingRoomProvider 내부에서 사용되어야 합니다.", 10 | ); 11 | } 12 | return context; 13 | } 14 | 15 | export { useWaitingContext }; 16 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/provider/WaitingRoomProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useSocketIO } from "@/shared/hooks/useSocketIo"; 2 | import React from "react"; 3 | 4 | interface WaitingRoomContextType { 5 | socket: ReturnType; 6 | isBettingStarted: boolean; 7 | setIsBettingStarted: React.Dispatch>; 8 | } 9 | 10 | const WaitingRoomContext = React.createContext(null!); 11 | 12 | function WaitingRoomProvider({ children }: { children: React.ReactNode }) { 13 | const [isBettingStarted, setIsBettingStarted] = React.useState(false); 14 | 15 | const socket = useSocketIO({ 16 | url: "/api/betting", 17 | onConnect: () => { 18 | console.log("투표 대기 방에서 소켓 연결을 성공 했습니다."); 19 | }, 20 | onDisconnect: (reason) => { 21 | console.error("투표 대기 방에서 소켓이 끊어졌습니다."); 22 | if (reason === "io server disconnect") { 23 | socket.reconnect(); 24 | } 25 | }, 26 | onError: (error) => { 27 | console.error("투표 대기 방에서 소켓 에러가 발생했습니다."); 28 | console.error(error); 29 | }, 30 | }); 31 | 32 | const value = React.useMemo( 33 | () => ({ 34 | socket, 35 | isBettingStarted, 36 | setIsBettingStarted, 37 | }), 38 | [socket, isBettingStarted, setIsBettingStarted], 39 | ); 40 | 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | } 47 | 48 | export { WaitingRoomProvider, WaitingRoomContext }; 49 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/style.module.css: -------------------------------------------------------------------------------- 1 | @keyframes icon-anime { 2 | 0% { 3 | .copy rect { 4 | transform: translate(-4px, -4px); 5 | } 6 | .copy path { 7 | transform: translate(3px, 3px); 8 | } 9 | } 10 | 11 | 100% { 12 | .copy rect { 13 | transform: translate(0px, 0px); 14 | } 15 | .copy path { 16 | transform: translate(0px, 0px); 17 | } 18 | } 19 | } 20 | 21 | .copy:hover { 22 | animation: icon-anime 1s infinite; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/ui/AdminFooter/CancleButton.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from "@tanstack/react-router"; 2 | import { useUserContext } from "@/shared/hooks/useUserContext"; 3 | 4 | function CancleVottingButton() { 5 | const navigate = useNavigate(); 6 | const { roomId } = useParams({ 7 | from: "/betting_/$roomId/waiting", 8 | }); 9 | const { setUserInfo } = useUserContext(); 10 | 11 | async function cancleBettingRoom() { 12 | try { 13 | const response = await fetch(`/api/betrooms/${roomId}`, { 14 | method: "DELETE", 15 | }); 16 | if (!response.ok) throw new Error("방 삭제에 실패했습니다."); 17 | 18 | setUserInfo({ role: "user", roomId: undefined }); 19 | navigate({ to: "/my-page" }); 20 | } catch (error) { 21 | navigate({ to: "/my-page" }); 22 | console.error(error); 23 | } 24 | } 25 | 26 | return ( 27 | 33 | ); 34 | } 35 | 36 | export { CancleVottingButton }; 37 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/ui/AdminFooter/ParticipateButton.tsx: -------------------------------------------------------------------------------- 1 | import { useWaitingContext } from "../../hooks/use-waiting-context"; 2 | import React from "react"; 3 | import { useParams } from "@tanstack/react-router"; 4 | 5 | function ParticipateButton({ 6 | setSnackbarOpen, 7 | }: { 8 | setSnackbarOpen: React.Dispatch>; 9 | }) { 10 | const { roomId } = useParams({ 11 | from: "/betting_/$roomId/waiting", 12 | }); 13 | const { socket } = useWaitingContext(); 14 | const [isBettingStarted, setIsBettingStarted] = React.useState(false); 15 | 16 | React.useEffect(() => { 17 | socket.on("startBetting", () => { 18 | setIsBettingStarted(true); 19 | }); 20 | 21 | return () => { 22 | socket.off("startBetting"); 23 | }; 24 | }, [socket, roomId]); 25 | 26 | async function participateVote() { 27 | try { 28 | const response = await fetch(`/api/betrooms/${roomId}`); 29 | if (!response.ok) throw new Error("방 정보를 가져오는데 실패했습니다."); 30 | const json = await response.json(); 31 | const { channel } = json.data; 32 | 33 | if (channel.status === "active") { 34 | window.location.href = `/betting/${roomId}/vote/admin`; 35 | } else { 36 | console.error("베팅이 아직 시작되지 않았습니다."); 37 | } 38 | } catch (error) { 39 | setSnackbarOpen(true); 40 | console.error("Error during navigation:", error); 41 | } 42 | } 43 | 44 | return ( 45 | 52 | ); 53 | } 54 | 55 | export { ParticipateButton }; 56 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/ui/AdminFooter/StartVotingButton.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useRouter } from "@tanstack/react-router"; 2 | import { responseBetRoomInfo } from "@betting-duck/shared"; 3 | import { z } from "zod"; 4 | import { getBettingRoomInfo } from "@/features/betting-page/api/getBettingRoomInfo"; 5 | import React from "react"; 6 | 7 | function StartVotingButton({ 8 | bettingRoomInfo, 9 | }: { 10 | bettingRoomInfo: z.infer; 11 | }) { 12 | const navigate = useNavigate(); 13 | const router = useRouter(); 14 | const { channel } = bettingRoomInfo; 15 | const roomId = channel.id; 16 | const [isBettingStart, setIsBettingStart] = React.useState(false); 17 | 18 | async function startBettingRoom() { 19 | try { 20 | const bettingRoomInfo = await getBettingRoomInfo(roomId); 21 | if (!bettingRoomInfo) { 22 | throw new Error("방 정보를 불러오는데 실패했습니다."); 23 | } 24 | if (bettingRoomInfo.channel.status === "active") { 25 | console.log("배팅이 이미 시작되었습니다."); 26 | await router.invalidate(); 27 | return navigate({ 28 | to: "/betting/$roomId/vote/admin", 29 | replace: true, 30 | params: { roomId }, 31 | }); 32 | } 33 | 34 | const response = await fetch(`/api/betrooms/start/${roomId}`, { 35 | method: "PATCH", 36 | }); 37 | if (!response.ok) throw new Error("배팅 시작에 실패했습니다."); 38 | setIsBettingStart(true); 39 | } catch (error) { 40 | console.error(error); 41 | } 42 | } 43 | 44 | return ( 45 | 52 | ); 53 | } 54 | 55 | export { StartVotingButton }; 56 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/ui/AdminFooter/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CancleVottingButton } from "./CancleButton"; 3 | import { StartVotingButton } from "./StartVotingButton"; 4 | import { responseBetRoomInfo } from "@betting-duck/shared"; 5 | import { z } from "zod"; 6 | import { ParticipateButton } from "./ParticipateButton"; 7 | 8 | function AdminFooter({ 9 | bettingRoomInfo, 10 | setSnackbarOpen, 11 | }: { 12 | bettingRoomInfo: z.infer; 13 | setSnackbarOpen: React.Dispatch>; 14 | }) { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export { AdminFooter }; 25 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/ui/WaitingRoomHeader/VotingStatusCard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogTrigger, 4 | DialogContent, 5 | } from "@/shared/components/Dialog"; 6 | import { EditIcon, InfoIcon } from "@/shared/icons"; 7 | import { EditFormStatusForm } from "./EditFormStatusForm"; 8 | import React from "react"; 9 | import { z } from "zod"; 10 | import { responseBetRoomInfo } from "@betting-duck/shared"; 11 | 12 | const VotingStatusCard = React.memo( 13 | ({ 14 | bettingRoomInfo, 15 | }: { 16 | bettingRoomInfo: z.infer; 17 | }) => { 18 | const { channel } = bettingRoomInfo; 19 | 20 | return ( 21 |
22 |
23 |
24 | 25 | 투표 생성 정보 26 |
27 | {bettingRoomInfo.channel.isAdmin && ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | )} 37 |
38 |

{channel.title}

39 |
40 | ); 41 | }, 42 | ); 43 | 44 | VotingStatusCard.displayName = "VotingStatusCard"; 45 | 46 | export { VotingStatusCard }; 47 | -------------------------------------------------------------------------------- /frontend/src/features/waiting-room/ui/WaitingRoomHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import { VotingStatusCard } from "./VotingStatusCard"; 2 | import { responseBetRoomInfo } from "@betting-duck/shared"; 3 | import { z } from "zod"; 4 | 5 | type WaitingRoomInfo = z.infer; 6 | 7 | function WaitingRoomHeader({ 8 | bettingRoomInfo, 9 | }: { 10 | bettingRoomInfo: z.infer; 11 | }) { 12 | return ( 13 |
14 |

투표대기창

15 | 16 |
17 | ); 18 | } 19 | 20 | export { WaitingRoomHeader, type WaitingRoomInfo }; 21 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import { StrictMode } from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import { RouterProvider, createRouter } from "@tanstack/react-router"; 5 | import { routeTree } from "./routeTree.gen"; 6 | import { Auth } from "@/shared/lib/auth/auth"; 7 | import { ThemeProvider, createTheme } from "@mui/material/styles"; 8 | import { GlobalErrorComponent } from "./shared/components/Error/GlobalError"; 9 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 10 | 11 | const queryClient = new QueryClient({ 12 | defaultOptions: { 13 | queries: { 14 | retry: false, 15 | refetchOnWindowFocus: false, 16 | }, 17 | }, 18 | }); 19 | 20 | type RouterContext = { 21 | auth: Auth; 22 | queryClient: QueryClient; 23 | }; 24 | 25 | const router = createRouter({ 26 | routeTree, 27 | context: { 28 | auth: {} as Auth, 29 | queryClient, 30 | } as RouterContext, 31 | defaultPreload: "intent", 32 | defaultErrorComponent: ({ error }) => ( 33 | 34 | ), 35 | }); 36 | 37 | declare module "@tanstack/react-router" { 38 | interface Register { 39 | router: typeof router; 40 | } 41 | } 42 | 43 | const theme = createTheme(); 44 | 45 | createRoot(document.getElementById("root")!).render( 46 | 47 | 48 | 49 | 50 | 51 | 52 | , 53 | ); 54 | 55 | export { type RouterContext }; 56 | -------------------------------------------------------------------------------- /frontend/src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | // src/mocks/browser.ts 2 | import { setupWorker } from "msw/browser"; 3 | import { handlers, setupWebSocketServer } from "./chat/handler"; 4 | 5 | export const worker = setupWorker(...handlers); 6 | 7 | export const startMockServices = async () => { 8 | await worker.start({ 9 | onUnhandledRequest: "bypass", 10 | }); 11 | 12 | setupWebSocketServer(); 13 | 14 | console.log("🔶 Mock Service Worker started"); 15 | console.log("🔶 WebSocket Server started on ws://localhost:8080"); 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; 2 | import { RootHeader } from "@/shared/components/RootHeader"; 3 | import { RootSideBar } from "@/shared/components/RootSideBar"; 4 | import { UserProvider } from "@/app/provider/UserProvider"; 5 | import { GlobalErrorComponent } from "@/shared/components/Error/GlobalError"; 6 | import { cn } from "@/shared/misc"; 7 | import { useLayout } from "@/shared/hooks/useLayout"; 8 | import { LayoutProvider } from "@/app/provider/LayoutProvider"; 9 | import { RouterContext } from "@/main"; 10 | import { authQueries } from "@/shared/lib/auth/authQuery"; 11 | import React from "react"; 12 | import { LoadingAnimation } from "@/shared/components/Loading"; 13 | 14 | export const Route = createRootRouteWithContext()({ 15 | component: () => ( 16 | 17 | 18 | 19 | }> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ), 28 | loader: (opts) => opts.context.queryClient.ensureQueryData(authQueries), 29 | errorComponent: ({ error }) => , 30 | }); 31 | 32 | const layoutStyles = { 33 | default: "max-w-[520px]", 34 | wide: "max-w-[1200px]", 35 | } as const; 36 | 37 | function RootLayout({ children }: { children: React.ReactNode }) { 38 | const { layoutType } = useLayout(); 39 | 40 | return ( 41 |
49 | {children} 50 |
51 | ); 52 | } 53 | 54 | export { RootLayout }; 55 | -------------------------------------------------------------------------------- /frontend/src/routes/betting_.$roomId.vote.admin.tsx: -------------------------------------------------------------------------------- 1 | import { BettingPageAdmin } from "@/features/betting-page-admin"; 2 | import { createFileRoute, redirect } from "@tanstack/react-router"; 3 | import { getBettingRoomInfo } from "@/features/betting-page/api/getBettingRoomInfo"; 4 | import { GlobalErrorComponent } from "@/shared/components/Error/GlobalError"; 5 | 6 | export const Route = createFileRoute("/betting_/$roomId/vote/admin")({ 7 | component: BettingPageAdmin, 8 | beforeLoad: async ({ params }) => { 9 | const { roomId } = params; 10 | const roomInfo = await getBettingRoomInfo(roomId); 11 | if (!roomInfo) { 12 | throw new Error("방 정보를 불러오는데 실패했습니다."); 13 | } 14 | 15 | if (!roomInfo.channel.isAdmin) { 16 | throw redirect({ 17 | to: `/betting/${roomId}/vote/voting`, 18 | }); 19 | } 20 | }, 21 | 22 | shouldReload: () => true, 23 | errorComponent: ({ error }) => { 24 | return ; 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /frontend/src/routes/betting_.$roomId.vote.voting.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from "@tanstack/react-router"; 2 | import { BettingPage } from "@/features/betting-page"; 3 | import { getBettingRoomInfo } from "@/features/betting-page/api/getBettingRoomInfo"; 4 | 5 | export const Route = createFileRoute("/betting_/$roomId/vote/voting")({ 6 | component: BettingPage, 7 | beforeLoad: async ({ params }) => { 8 | const { roomId } = params; 9 | 10 | const bettingRoomInfo = await getBettingRoomInfo(roomId); 11 | 12 | if (bettingRoomInfo?.channel.isAdmin) { 13 | throw redirect({ 14 | to: "/betting/$roomId/vote/admin", 15 | params: { roomId }, 16 | }); 17 | } 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /frontend/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogTrigger, 4 | DialogContent, 5 | } from "@/shared/components/Dialog"; 6 | import { createFileRoute, redirect } from "@tanstack/react-router"; 7 | 8 | const user = undefined; 9 | 10 | export const Route = createFileRoute("/")({ 11 | loader: () => { 12 | if (!user) { 13 | throw redirect({ 14 | to: "/login", 15 | }); 16 | } 17 | }, 18 | component: () => ( 19 | 20 | 21 | 24 | 25 | 26 |
27 |

기본 Dialog

28 |
29 |
30 |

Dialog 내용이 여기에 들어갑니다.

31 |
32 |
33 | 36 |
37 |
38 |
39 | ), 40 | }); 41 | -------------------------------------------------------------------------------- /frontend/src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { LoginPage } from "@/features/login-page"; 2 | import { GlobalErrorComponent } from "@/shared/components/Error/GlobalError"; 3 | import { authQueries } from "@/shared/lib/auth/authQuery"; 4 | import { AuthStatusTypeSchema } from "@/shared/lib/auth/guard"; 5 | import { createFileRoute, redirect } from "@tanstack/react-router"; 6 | 7 | export const Route = createFileRoute("/login")({ 8 | component: Component, 9 | loader: async ({ context: { queryClient } }) => { 10 | const queryData = await queryClient.ensureQueryData(authQueries); 11 | const parsedData = AuthStatusTypeSchema.safeParse(queryData); 12 | 13 | if (parsedData.success && parsedData.data.isAuthenticated) { 14 | return redirect({ 15 | to: "/my-page", 16 | }); 17 | } 18 | }, 19 | errorComponent: ({ error }) => , 20 | }); 21 | 22 | function Component() { 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/routes/my-page.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from "@tanstack/react-router"; 2 | import { ErrorComponent } from "@/shared/components/Error"; 3 | import { MyPage } from "@/features/my-page"; 4 | import { ErrorMyPage } from "@/features/my-page/error"; 5 | import { ROUTES } from "@/shared/config/route"; 6 | 7 | export const Route = createFileRoute("/my-page")({ 8 | beforeLoad: async () => { 9 | const tokenResponse = await fetch("/api/users/token", { 10 | headers: { 11 | "Cache-Control": "stale-while-revalidate", 12 | Pragma: "no-cache", 13 | }, 14 | credentials: "include", 15 | }); 16 | if (!tokenResponse.ok) { 17 | throw redirect({ 18 | to: "/require-login", 19 | search: { from: encodeURIComponent(ROUTES.MYPAGE) }, 20 | }); 21 | } 22 | }, 23 | component: MyPage, 24 | shouldReload: () => true, 25 | errorComponent: ({ error }) => { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/src/shared/api/responseBettingRoomInfo.ts: -------------------------------------------------------------------------------- 1 | import { responseBetRoomInfo } from "@betting-duck/shared"; 2 | import { EnsureQueryDataOptions } from "@tanstack/react-query"; 3 | import { z } from "zod"; 4 | 5 | interface BetRoomInfo { 6 | roomInfo: EnsureQueryDataOptions; 7 | } 8 | const StringSchema = z.string(); 9 | 10 | async function responseBettingRoomInfo(roomId: string) { 11 | try { 12 | const response = await fetch(`/api/betrooms/${roomId}`); 13 | if (!response.ok) { 14 | throw new Error("베팅 방 정보를 불러오는데 실패했습니다."); 15 | } 16 | 17 | const { data } = await response.json(); 18 | const result = responseBetRoomInfo.safeParse(data); 19 | if (!result.success) { 20 | console.error(result.error.errors); 21 | throw new Error("베팅 방 정보를 파싱하는데 실패했습니다."); 22 | } 23 | return result.data; 24 | } catch (error) { 25 | console.error(error); 26 | } 27 | } 28 | 29 | const betRoomQueries = (roomId: string): BetRoomInfo => ({ 30 | roomInfo: { 31 | queryKey: ["betRoom", "info", roomId], 32 | queryFn: async ({ queryKey }) => { 33 | const [, , roomId] = queryKey; 34 | const parsedRoomId = StringSchema.safeParse(roomId); 35 | if (!parsedRoomId.success) { 36 | throw new Error("베팅 방 정보를 불러오는데 실패"); 37 | } 38 | return await responseBettingRoomInfo(parsedRoomId.data); 39 | }, 40 | }, 41 | }); 42 | 43 | export { responseBettingRoomInfo, betRoomQueries }; 44 | -------------------------------------------------------------------------------- /frontend/src/shared/api/responseEndBetroom.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { responseBettingRoomInfo } from "./responseBettingRoomInfo"; 3 | 4 | const responseBetRoomInfo = z.object({ 5 | status: z.enum(["200", "204", "400", "401", "409", "500"]), 6 | data: z.object({ 7 | bet_room_id: z.string(), 8 | message: z.string(), 9 | }), 10 | }); 11 | 12 | async function responseEndRoom( 13 | betroomId: string, 14 | winningOption: "option1" | "option2", 15 | ) { 16 | const response = await fetch(`/api/betrooms/end/${betroomId}`, { 17 | method: "POST", 18 | headers: { 19 | "Content-Type": "application/json", 20 | }, 21 | body: JSON.stringify({ winning_option: winningOption }), 22 | }); 23 | if (!response.ok) { 24 | throw new Error("베팅 룸을 종료하는데 실패했습니다."); 25 | } 26 | 27 | const { data } = await response.json(); 28 | const result = responseBetRoomInfo.safeParse(data); 29 | if (result.error) { 30 | console.error(result.error); 31 | throw new Error("베팅 룸을 종료하는데 실패했습니다."); 32 | } 33 | 34 | const bettingRoomInfo = await responseBettingRoomInfo(betroomId); 35 | if (!bettingRoomInfo?.channel.isAdmin) { 36 | throw new Error("방장만 베팅 룸을 종료할 수 있습니다."); 37 | } 38 | } 39 | 40 | export { responseEndRoom }; 41 | -------------------------------------------------------------------------------- /frontend/src/shared/api/responseUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { responseUserInfoSchema } from "@betting-duck/shared"; 2 | import { z } from "zod"; 3 | 4 | async function responseUserInfo(): Promise< 5 | z.infer 6 | > { 7 | const response = await fetch("/api/users/userInfo"); 8 | if (!response.ok) { 9 | throw new Error( 10 | `사용자 정보를 불러오는데 실패했습니다. Status: ${response.status}`, 11 | ); 12 | } 13 | 14 | const { data } = await response.json(); 15 | const result = responseUserInfoSchema.safeParse(data); 16 | if (!result.success) { 17 | console.error(result.error); 18 | throw new Error("사용자 정보를 불러오는데 실패했습니다."); 19 | } 20 | 21 | return result.data; 22 | } 23 | 24 | export { responseUserInfo }; 25 | -------------------------------------------------------------------------------- /frontend/src/shared/api/responseUserToken.ts: -------------------------------------------------------------------------------- 1 | async function responseUserToken() { 2 | const response = await fetch("/api/users/token", { 3 | headers: { 4 | "Cache-Control": "stale-while-revalidate", 5 | Pragma: "no-cache", 6 | }, 7 | credentials: "include", 8 | }); 9 | if (!response.ok) { 10 | throw new Error("토큰을 불러오는데 실패했습니다."); 11 | } 12 | return response; 13 | } 14 | 15 | export { responseUserToken }; 16 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | function Button() { 2 | return
Button
; 3 | } 4 | 5 | export default Button; 6 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Dialog/content.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DialogContext } from "."; 3 | import { cn } from "../../misc"; 4 | import { createPortal } from "react-dom"; 5 | import { FocusLock } from "./focus-lock"; 6 | 7 | const DialogPortal = React.memo( 8 | ({ 9 | children, 10 | className, 11 | }: { 12 | children: React.ReactNode; 13 | className?: string; 14 | }) => { 15 | return createPortal( 16 | 17 |
21 | 22 |
23 | {children} 24 |
25 |
26 | , 27 | document.body, 28 | ); 29 | }, 30 | ); 31 | 32 | DialogPortal.displayName = "DialogPortal"; 33 | 34 | const DialogContent = React.memo( 35 | ({ 36 | children, 37 | className, 38 | }: { 39 | children: React.ReactNode; 40 | className?: string; 41 | }) => { 42 | const { isOpen } = React.useContext(DialogContext); 43 | 44 | if (!isOpen) return null; 45 | 46 | return {children}; 47 | }, 48 | ); 49 | 50 | DialogContent.displayName = "DialogContent"; 51 | 52 | export { DialogContent }; 53 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Dialog/context.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type DialogContextType = { 4 | isOpen: boolean; 5 | toggleOpen: () => void; 6 | }; 7 | 8 | const DialogContext = React.createContext({ 9 | isOpen: false, 10 | toggleOpen: () => {}, 11 | }); 12 | 13 | const DialogStateProvider = React.memo( 14 | ({ children }: { children: React.ReactNode }) => { 15 | const [isOpen, setIsOpen] = React.useState(false); 16 | const toggleOpen = React.useCallback(() => setIsOpen((prev) => !prev), []); 17 | 18 | const value = React.useMemo( 19 | () => ({ 20 | isOpen, 21 | toggleOpen, 22 | }), 23 | [isOpen, toggleOpen], 24 | ); 25 | 26 | return ( 27 | {children} 28 | ); 29 | }, 30 | ); 31 | 32 | DialogStateProvider.displayName = "DialogStateProvider"; 33 | 34 | export { DialogContext, DialogStateProvider }; 35 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Dialog/focus-lock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useFocusLock } from "./hook"; 3 | import { DialogContext } from "."; 4 | 5 | const FocusLock = React.memo(({ children }: { children: React.ReactNode }) => { 6 | const containerRef = React.useRef(null); 7 | const { toggleOpen } = React.useContext(DialogContext); 8 | useFocusLock(containerRef, toggleOpen); 9 | 10 | return ( 11 |
18 | {children} 19 |
20 | ); 21 | }); 22 | 23 | FocusLock.displayName = "FocusLock"; 24 | 25 | export { FocusLock }; 26 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DialogContent } from "./content"; 3 | import { DialogTrigger } from "./trigger"; 4 | import { DialogStateProvider, DialogContext } from "./context"; 5 | 6 | function Dialog({ children }: { children: React.ReactNode }) { 7 | return {children}; 8 | } 9 | 10 | export { Dialog, DialogContent, DialogTrigger, DialogContext }; 11 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Dialog/trigger.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DialogContext } from "."; 3 | import { Slot } from "@radix-ui/react-slot"; 4 | 5 | interface DialogTriggerProps extends React.HTMLAttributes { 6 | asChild?: boolean; 7 | } 8 | 9 | const DialogTrigger = React.memo( 10 | ({ asChild, ...props }: DialogTriggerProps) => { 11 | const { toggleOpen } = React.useContext(DialogContext); 12 | const Comp = asChild ? Slot : "button"; 13 | 14 | const handleClick = React.useCallback( 15 | (e: React.MouseEvent) => { 16 | e.preventDefault(); 17 | toggleOpen(); 18 | if (props.onClick) { 19 | props.onClick(e as unknown as React.MouseEvent); 20 | } 21 | }, 22 | [toggleOpen, props], 23 | ); 24 | 25 | return ; 26 | }, 27 | ); 28 | 29 | DialogTrigger.displayName = "DialogTrigger"; 30 | 31 | export { DialogTrigger }; 32 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Error/GuestError.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "@tanstack/react-router"; 2 | 3 | function GuestErrorComponent({ 4 | to, 5 | children, 6 | feature = "기능", 7 | }: { 8 | to: string; 9 | children: React.ReactNode; 10 | feature?: string; 11 | }) { 12 | const navigate = useNavigate(); 13 | return ( 14 |
15 |
22 |
23 | 권한이 존재하지 않습니다 24 |

25 | 회원 가입 후 이용 해주세요! 26 |

27 |
28 |
29 |

30 | {feature}을 이용하기 위해서는 31 | 32 | 회원가입 33 | {" "} 34 | 이 필요합니다! 35 |

36 |
37 | 47 |
48 |
{children}
49 |
50 | ); 51 | } 52 | 53 | export { GuestErrorComponent }; 54 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Error/index.tsx: -------------------------------------------------------------------------------- 1 | function ErrorComponent({ 2 | error, 3 | to, 4 | children, 5 | feature = "기능", 6 | }: { 7 | error?: Error; 8 | to: string; 9 | children: React.ReactNode; 10 | feature?: string; 11 | }) { 12 | return ( 13 |
14 |
21 |
22 | {error ? error.message : ""} 23 |

로그인 후 이용 해주세요!

24 |
25 |
26 |

27 | {feature}를 이용하기 위해서는 로그인이 필요합니다! 28 |

29 |

30 | 회원가입이 귀찮으시다면{" "} 31 | 32 | 비회원 33 | {" "} 34 | 로그인을 해주세요! 35 |

36 |
37 | 45 |
46 |
{children}
47 |
48 | ); 49 | } 50 | 51 | export { ErrorComponent }; 52 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Image = React.forwardRef< 4 | HTMLImageElement, 5 | React.ImgHTMLAttributes 6 | >((props, ref) => { 7 | return ; 8 | }); 9 | 10 | export { Image }; 11 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | 3 | function LoadingAnimation() { 4 | const circles = [ 5 | { 6 | cx: 70, 7 | cy: "50%", 8 | r: "1.25cqh", 9 | fill: "#8F76FF", 10 | }, 11 | { 12 | cx: 170, 13 | cy: "50%", 14 | r: "1.25cqh", 15 | fill: "#FF7E76", 16 | }, 17 | { cx: 270, cy: "50%", r: "1.25cqh", fill: "#8F76FF" }, 18 | { 19 | cx: 370, 20 | cy: "50%", 21 | r: "1.25cqh", 22 | fill: "#98DF9F", 23 | }, 24 | { 25 | cx: 470, 26 | cy: "50%", 27 | r: "1.25cqh", 28 | fill: "#8F76FF", 29 | }, 30 | ]; 31 | 32 | return ( 33 |
34 | 41 | {circles.map((circle, index) => { 42 | const startY = 10 - 7.5; 43 | const endY = 7.5 + 13; 44 | 45 | return ( 46 | 65 | ); 66 | })} 67 | 68 |
69 | ); 70 | } 71 | 72 | export { LoadingAnimation }; 73 | -------------------------------------------------------------------------------- /frontend/src/shared/components/PercentageDisplay/PercentageDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { ProgressBar } from "@/shared/components/ProgressBar"; 2 | 3 | import React from "react"; 4 | 5 | const PercentageDisplay = React.memo(function PercentageDisplay({ 6 | percentage, 7 | index, 8 | }: { 9 | percentage: number; 10 | index: number; 11 | }) { 12 | return ( 13 |
14 | 19 | {percentage}% 20 | 21 | 26 |
27 | ); 28 | }); 29 | 30 | export { PercentageDisplay }; 31 | -------------------------------------------------------------------------------- /frontend/src/shared/components/ProgressBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface CustomCSSProperties extends React.CSSProperties { 4 | "--track"?: string; 5 | "--progress"?: string; 6 | } 7 | 8 | interface ProgressBarProps extends React.HTMLAttributes { 9 | value: number; 10 | uses: "winning" | "losing" | "default"; 11 | max?: number; 12 | label?: string; 13 | style?: CustomCSSProperties; 14 | } 15 | 16 | function ProgressBar({ 17 | label = "", 18 | max = 100, 19 | value, 20 | uses, 21 | ...rest 22 | }: ProgressBarProps) { 23 | const trackColor = 24 | uses === "winning" ? "#D6DEF8" : uses === "losing" ? "#F8D6D6" : "#DDC7FC"; 25 | const progressColor = 26 | uses === "winning" ? "#4B78F7" : uses === "losing" ? "#DE3390" : "#f0f4fa"; 27 | 28 | return ( 29 | 51 | ); 52 | } 53 | 54 | export { ProgressBar }; 55 | -------------------------------------------------------------------------------- /frontend/src/shared/components/RootHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import { LogoIcon } from "@/shared/icons"; 2 | import { Image } from "@/shared/components/Image"; 3 | import waitingUserImage from "@assets/images/waiting-user.png"; 4 | import { Link } from "@tanstack/react-router"; 5 | import { useSuspenseQuery } from "@tanstack/react-query"; 6 | import { authQueries } from "@/shared/lib/auth/authQuery"; 7 | 8 | function UserInfo({ nickname }: { nickname: string }) { 9 | return ( 10 |
11 |
12 | 대기 중인 사용자 이미지 18 |
19 | {nickname} 20 |
21 | ); 22 | } 23 | 24 | function RootHeader() { 25 | const { data: authData, status } = useSuspenseQuery({ 26 | queryKey: authQueries.queryKey, 27 | queryFn: authQueries.queryFn, 28 | }); 29 | const nickname = authData?.userInfo?.nickname; 30 | 31 | if (status !== "success" || !authData?.userInfo?.nickname) { 32 | return ( 33 |
34 | 38 | 39 |

Betting Duck

40 | 41 |
42 | ); 43 | } 44 | 45 | return ( 46 |
47 | 51 | 52 |

Betting Duck

53 | 54 | 55 |
56 | ); 57 | } 58 | 59 | export { RootHeader }; 60 | -------------------------------------------------------------------------------- /frontend/src/shared/components/RootSideBar/item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createLink, LinkComponent, useLocation } from "@tanstack/react-router"; 3 | 4 | interface LinkComponentProps 5 | extends React.AnchorHTMLAttributes { 6 | icon: React.ReactNode; 7 | label: string; 8 | } 9 | 10 | const Link = React.forwardRef( 11 | ({ icon, label, className, ...props }, ref) => { 12 | const location = useLocation(); 13 | const isChecked = location.pathname.includes(label.split(" ")[0]); 14 | 15 | return ( 16 | 17 | 29 | 30 | ); 31 | }, 32 | ); 33 | 34 | const CreatedLinkComponent = createLink(Link); 35 | 36 | const NavItem: LinkComponent = (props) => { 37 | return ( 38 | 43 | ); 44 | }; 45 | 46 | export { NavItem }; 47 | -------------------------------------------------------------------------------- /frontend/src/shared/components/RootSideBar/style.module.css: -------------------------------------------------------------------------------- 1 | .navigator { 2 | content: ""; 3 | position: absolute; 4 | 5 | top: 0; 6 | left: 0; 7 | 8 | width: 4px; 9 | height: 46px; 10 | 11 | background: #6e29da; 12 | transition: transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 13 | 14 | transform: translateY(calc(48px * calc(1.5 * var(--navigator-position)))); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/shared/components/input/InputField.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface InputFieldProps extends React.InputHTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | function InputField({ 8 | children, 9 | value, 10 | onChange, 11 | autoComplete = "off", 12 | ...props 13 | }: InputFieldProps) { 14 | return ( 15 | 30 | ); 31 | } 32 | 33 | export { InputField }; 34 | -------------------------------------------------------------------------------- /frontend/src/shared/config/environment.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | socketUrl: __SOCKET_URL__, 3 | appEnv: __APP_ENV__, 4 | isDevelopment: __APP_ENV__ === "development", 5 | isProduction: __APP_ENV__ === "production", 6 | } as const; 7 | 8 | export type Config = typeof config; 9 | -------------------------------------------------------------------------------- /frontend/src/shared/config/route.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const ROUTE_PATHS = { 4 | LOGIN: "/login", 5 | GUEST_LOGIN: "/guest-login", 6 | GUEST_CREATE_VOTE: "/guest-vote", 7 | BETTING: "/betting", 8 | BETTING_WAITING: "/betting_/$roomId/waiting", 9 | BETTING_VOTE: "/betting_/$roomId/vote", 10 | CREATE_VOTE: "/create-vote", 11 | MYPAGE: "/my-page", 12 | REQUIRE_LOGIN: "/require-login", 13 | } as const; 14 | 15 | type RouteKeys = keyof typeof ROUTE_PATHS; 16 | type Routes = { 17 | readonly [K in RouteKeys]: string; 18 | }; 19 | 20 | const ROUTES: Routes = Object.freeze(ROUTE_PATHS); 21 | 22 | // zod enum 스키마 생성 23 | const ROUTE_PATH_ENUM = z.enum([ 24 | "/login", 25 | "/betting", 26 | "/betting_/$roomId/waiting", 27 | "/betting_/$roomId/vote", 28 | "/create-vote", 29 | "/my-page", 30 | "/require-login", 31 | "/guest-login", 32 | "/guest-vote", 33 | ]); 34 | 35 | const ROUTES_PATH = ROUTE_PATH_ENUM.options; 36 | 37 | export { ROUTES, ROUTES_PATH, ROUTE_PATH_ENUM, type Routes }; 38 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "@tanstack/react-query"; 2 | import { AuthStatusTypeSchema } from "../lib/auth/guard"; 3 | import { z } from "zod"; 4 | import { authQueries } from "../lib/auth/authQuery"; 5 | 6 | type UserInfoType = z.infer; 7 | 8 | export function useUpdateUserStatus() { 9 | const queryClient = useQueryClient(); 10 | 11 | const updateAuthStatus = ( 12 | isAuthenticated: boolean, 13 | userInfo: UserInfoType["userInfo"], 14 | ) => { 15 | queryClient.setQueryData(authQueries.queryKey, { 16 | data: { 17 | isAuthenticated, 18 | userInfo, 19 | }, 20 | }); 21 | }; 22 | return { updateAuthStatus }; 23 | } 24 | // const login = async (/* login params */) => { 25 | // // 로그인 로직 26 | // // ... 27 | // updateAuthStatus(true, userInfo); 28 | // }; 29 | 30 | // const logout = async () => { 31 | // // 로그아웃 로직 32 | // // ... 33 | // updateAuthStatus(false, defaultUserInfo); 34 | // }; 35 | 36 | // return { login, logout }; 37 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useBettingRoomInfo.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { betRoomQueries } from "../api/responseBettingRoomInfo"; 3 | import { z } from "zod"; 4 | 5 | const StringSchema = z.string(); 6 | 7 | export function useBettingRoomInfo(roomId: string) { 8 | const parsedRoomId = StringSchema.safeParse(roomId); 9 | if (!parsedRoomId.success) { 10 | throw new Error("베팅 방 정보를 불러오는데 실패"); 11 | } 12 | const { roomInfo } = betRoomQueries(parsedRoomId.data); 13 | return useQuery({ 14 | queryKey: roomInfo.queryKey, 15 | queryFn: roomInfo.queryFn, 16 | refetchOnMount: true, 17 | refetchOnWindowFocus: false, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useDuckCoin.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { responseUserInfoSchema } from "@betting-duck/shared"; 3 | 4 | function useDuckCoin() { 5 | const [duckCoin, setDuckCoin] = React.useState(0); 6 | 7 | React.useEffect(() => { 8 | (async () => { 9 | const response = await fetch("/api/users/userInfo"); 10 | if (!response.ok) { 11 | throw new Error("사용자 정보를 불러오는데 실패했습니다."); 12 | } 13 | 14 | const { data } = await response.json(); 15 | const result = responseUserInfoSchema.safeParse(data); 16 | if (!result.success) { 17 | console.error(result.error); 18 | throw new Error("사용자 정보를 불러오는데 실패했습니다."); 19 | } 20 | 21 | setDuckCoin(result.data.duck); 22 | })(); 23 | }, []); 24 | 25 | return duckCoin; 26 | } 27 | 28 | export { useDuckCoin }; 29 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useEffectOnce.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function useEffectOnce(effect: () => (() => void) | void) { 4 | const destroyRef = React.useRef<(() => void) | void>(); 5 | const effectCalled = React.useRef(false); 6 | const renderAfterCalled = React.useRef(false); 7 | const [, setVal] = React.useState(0); 8 | 9 | if (effectCalled.current) { 10 | renderAfterCalled.current = true; 11 | } 12 | 13 | React.useEffect(() => { 14 | // 이미 effect가 호출되었다면 더 이상 실행하지 않음 15 | if (!effectCalled.current) { 16 | destroyRef.current = effect(); 17 | effectCalled.current = true; 18 | setVal((val) => val + 1); // 리렌더링 강제 19 | } 20 | 21 | return () => { 22 | // cleanup은 항상 실행되어야 함 23 | if (destroyRef.current) { 24 | destroyRef.current(); 25 | } 26 | }; 27 | }, [effect]); 28 | 29 | return effectCalled.current; 30 | } 31 | 32 | export { useEffectOnce }; 33 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useLayout.tsx: -------------------------------------------------------------------------------- 1 | import { LayoutContext } from "@/app/provider/LayoutProvider"; 2 | import React from "react"; 3 | 4 | function useLayout() { 5 | const context = React.useContext(LayoutContext); 6 | if (!context) { 7 | throw new Error("useLayout must be used within a LayoutProvider"); 8 | } 9 | return context; 10 | } 11 | 12 | export { useLayout }; 13 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useLayoutShift.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLayout } from "./useLayout"; 3 | 4 | function useLayoutShift() { 5 | const { setLayoutType } = useLayout(); 6 | 7 | React.useEffect(() => { 8 | setLayoutType("wide"); 9 | 10 | return () => { 11 | setLayoutType("default"); 12 | }; 13 | }, [setLayoutType]); 14 | } 15 | 16 | export { useLayoutShift }; 17 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/usePreventLeave.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const usePreventLeave = (enabled: boolean = true, roomId: string) => { 4 | const message = 5 | "베팅 페이지에서 벗어나면 베팅이 취소됩니다. 정말로 나가시겠습니까?"; 6 | 7 | React.useEffect(() => { 8 | if (!enabled) return; 9 | 10 | const handleBeforeUnload = async (e: BeforeUnloadEvent) => { 11 | e.preventDefault(); 12 | e.returnValue = message; 13 | 14 | try { 15 | // 창이 닫히기 전 환불 요청 전송 16 | navigator.sendBeacon( 17 | `/api/betrooms/refund/${roomId}`, 18 | JSON.stringify({}), 19 | ); 20 | } catch (error) { 21 | console.error("Failed to send refund request:", error); 22 | } 23 | 24 | return message; 25 | }; 26 | 27 | window.addEventListener("beforeunload", handleBeforeUnload); 28 | 29 | return () => { 30 | window.removeEventListener("beforeunload", handleBeforeUnload); 31 | }; 32 | }, [enabled, message, roomId]); 33 | }; 34 | 35 | export { usePreventLeave }; 36 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useTrigger.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function useToggle(initValue = false): [boolean, () => void] { 4 | if (typeof initValue !== "boolean") { 5 | console.warn("useToggle의 인자값은 boolean이어야 합니다."); 6 | } 7 | 8 | const [isToggled, setToggled] = React.useState(initValue); 9 | 10 | const executeToggle = React.useCallback(() => { 11 | setToggled((prev) => !prev); 12 | }, []); 13 | 14 | return [isToggled, executeToggle]; 15 | } 16 | 17 | export default useToggle; 18 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useUserContext.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { UserContext } from "@/app/provider/UserProvider"; 3 | 4 | const useUserContext = () => { 5 | const context = React.useContext(UserContext); 6 | if (!context) { 7 | throw new Error("useContext must be used within a UserProvider"); 8 | } 9 | return context; 10 | }; 11 | 12 | export { useUserContext }; 13 | -------------------------------------------------------------------------------- /frontend/src/shared/hooks/useUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { EnsureQueryDataOptions, useQuery } from "@tanstack/react-query"; 2 | import { responseUserInfo } from "../api/responseUserInfo"; 3 | import { z } from "zod"; 4 | import { responseUserInfoSchema } from "@betting-duck/shared"; 5 | import { UserInfo } from "../types"; 6 | 7 | const USER_INFO_QUERY_KEY = ["userInfo"] as const; 8 | type USER_INFO_QUERY_KEY = typeof USER_INFO_QUERY_KEY; 9 | 10 | const userInfoQueries: EnsureQueryDataOptions< 11 | UserInfo, 12 | Error, 13 | UserInfo, 14 | USER_INFO_QUERY_KEY 15 | > = { 16 | queryKey: USER_INFO_QUERY_KEY, 17 | queryFn: async (): Promise> => { 18 | const userInfo = await responseUserInfo(); 19 | return userInfo; 20 | }, 21 | }; 22 | 23 | function useUserInfo() { 24 | return useQuery({ 25 | queryKey: userInfoQueries.queryKey, 26 | queryFn: userInfoQueries.queryFn, 27 | refetchOnMount: true, 28 | refetchOnWindowFocus: false, 29 | retry: 2, 30 | }); 31 | } 32 | 33 | export { userInfoQueries, useUserInfo, USER_INFO_QUERY_KEY }; 34 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/ArrowDownIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ArrowDownIcon = React.memo(() => ( 4 | 11 | 17 | 23 | 24 | )); 25 | 26 | export { ArrowDownIcon }; 27 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/ArrowUpIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ArrowUpIcon = React.memo(() => ( 4 | 11 | 17 | 23 | 24 | )); 25 | 26 | export { ArrowUpIcon }; 27 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/ChatIcon.tsx: -------------------------------------------------------------------------------- 1 | function ChatIcon() { 2 | return ( 3 | 11 | 18 | 19 | ); 20 | } 21 | 22 | export { ChatIcon }; 23 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/ConfirmIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Icon({ ...props }: React.SVGProps) { 4 | return ( 5 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export const ConfirmIcon = React.memo(Icon); 23 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/CopyIcon.tsx: -------------------------------------------------------------------------------- 1 | function CopyIcon({ ...props }: React.SVGProps) { 2 | return ( 3 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export { CopyIcon }; 23 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/CreateVoteIcon.tsx: -------------------------------------------------------------------------------- 1 | function CreateVoteIcon() { 2 | return ( 3 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export { CreateVoteIcon }; 16 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/EditIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Icon() { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export const EditIcon = React.memo(Icon); 24 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/EmailIcon.tsx: -------------------------------------------------------------------------------- 1 | function EmailIcon() { 2 | return ( 3 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export { EmailIcon }; 22 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/InfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Icon() { 4 | return ( 5 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export const InfoIcon = React.memo(Icon); 25 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/LinkIcon.tsx: -------------------------------------------------------------------------------- 1 | function LinkIcon({ ...props }: React.SVGProps) { 2 | return ( 3 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export { LinkIcon }; 22 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/LoginIDIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LoginIDIcon = React.memo(() => ( 4 | 11 | 12 | 19 | 26 | 27 | 28 | )); 29 | 30 | export { LoginIDIcon }; 31 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/LoginIcon.tsx: -------------------------------------------------------------------------------- 1 | function LoginIcon() { 2 | return ( 3 | 11 | 15 | 16 | ); 17 | } 18 | 19 | export { LoginIcon }; 20 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/LoginPasswordIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LoginPasswordIcon = React.memo(() => ( 4 | 11 | 12 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | )); 27 | 28 | export { LoginPasswordIcon }; 29 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/Logout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Icon() { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | 20 | const LogoutIcon = React.memo(Icon); 21 | 22 | export { LogoutIcon }; 23 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/TextIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const TextIcon = React.memo(() => ( 4 | 11 | 15 | 16 | )); 17 | 18 | export { TextIcon }; 19 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/TimerIcon.tsx: -------------------------------------------------------------------------------- 1 | function TimerIcon({ ...props }: React.SVGProps) { 2 | return ( 3 | 11 | 17 | 18 | ); 19 | } 20 | 21 | export { TimerIcon }; 22 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/TrophyIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function TrophyIcon({ ...props }: React.SVGProps) { 4 | return ( 5 | 13 | 14 | 18 | 19 | 20 | 21 | 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | export { TrophyIcon }; 34 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | function UserIcon() { 2 | return ( 3 | 11 | 18 | 25 | 26 | ); 27 | } 28 | 29 | export { UserIcon }; 30 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/WatingRoomIcon.tsx: -------------------------------------------------------------------------------- 1 | function WaitingRoomIcon() { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | } 15 | 16 | export { WaitingRoomIcon }; 17 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/index.ts: -------------------------------------------------------------------------------- 1 | import { LoginIcon } from "./LoginIcon"; 2 | import { LogoIcon } from "./LogoIcon"; 3 | import { UserIcon } from "./UserIcon"; 4 | import { ChatIcon } from "./ChatIcon"; 5 | import { CreateVoteIcon } from "./CreateVoteIcon"; 6 | import { ArrowDownIcon } from "./ArrowDownIcon"; 7 | import { ArrowUpIcon } from "./ArrowUpIcon"; 8 | import { TimerIcon } from "./TimerIcon"; 9 | import { TextIcon } from "./TextIcon"; 10 | import { DuckIcon } from "./DuckIcon"; 11 | import { TrophyIcon } from "./TrophyIcon"; 12 | import { PeoplesIcon } from "./PeoplesIcon"; 13 | import { LinkIcon } from "./LinkIcon"; 14 | import { WaitingRoomIcon } from "./WatingRoomIcon"; 15 | import { CopyIcon } from "./CopyIcon"; 16 | import { DuckCoinIcon } from "./DuckCoinIcon"; 17 | import { InfoIcon } from "./InfoIcon"; 18 | import { EditIcon } from "./EditIcon"; 19 | import { LoginIDIcon } from "./LoginIDIcon"; 20 | import { LoginPasswordIcon } from "./LoginPasswordIcon"; 21 | import { EmailIcon } from "./EmailIcon"; 22 | 23 | export { 24 | EditIcon, 25 | InfoIcon, 26 | TrophyIcon, 27 | PeoplesIcon, 28 | DuckCoinIcon, 29 | WaitingRoomIcon, 30 | CopyIcon, 31 | LinkIcon, 32 | LoginIcon, 33 | LogoIcon, 34 | UserIcon, 35 | ChatIcon, 36 | CreateVoteIcon, 37 | ArrowDownIcon, 38 | ArrowUpIcon, 39 | TimerIcon, 40 | TextIcon, 41 | DuckIcon, 42 | LoginIDIcon, 43 | LoginPasswordIcon, 44 | EmailIcon, 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/src/shared/lib/auth/auth.ts: -------------------------------------------------------------------------------- 1 | export const Auth: Auth = { 2 | status: "unauthorized", 3 | nickname: undefined, 4 | roomId: undefined, 5 | login(nickname: string) { 6 | this.status = "forbidden"; 7 | this.nickname = nickname; 8 | }, 9 | logout() { 10 | this.status = "unauthorized"; 11 | this.nickname = undefined; 12 | }, 13 | joinRoom(roomId: string) { 14 | this.status = "logged_in"; 15 | this.roomId = roomId; 16 | }, 17 | }; 18 | 19 | export type Auth = { 20 | login: (nickname: string) => void; 21 | logout: () => void; 22 | joinRoom: (roomId: string) => void; 23 | status: "logged_in" | "unauthorized" | "forbidden"; 24 | nickname?: string; 25 | roomId?: string; 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/shared/lib/auth/authQuery.ts: -------------------------------------------------------------------------------- 1 | import { checkAuthStatus } from "./guard"; 2 | import { QueryFunction } from "@tanstack/react-query"; 3 | import { AuthStatusTypeSchema } from "./guard"; 4 | import { z } from "zod"; 5 | 6 | const authQueries = { 7 | queryKey: ["auth"], 8 | queryFn: checkAuthStatus as QueryFunction< 9 | z.infer 10 | >, 11 | gcTime: 1000 * 60 * 60 * 24, 12 | staleTime: 1000 * 60 * 60, 13 | }; 14 | 15 | export { authQueries }; 16 | -------------------------------------------------------------------------------- /frontend/src/shared/lib/bettingRoomInfo.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from "@tanstack/react-query"; 2 | import { getBettingRoomInfo } from "@/features/betting-page/api/getBettingRoomInfo"; 3 | 4 | export const bettingRoomQueryKey = (roomId: string) => ["bettingRoom", roomId]; 5 | 6 | export function useBettingRoomQuery(roomId: string) { 7 | return useSuspenseQuery({ 8 | queryKey: bettingRoomQueryKey(roomId), 9 | queryFn: () => getBettingRoomInfo(roomId), 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/shared/lib/validateAccess.ts: -------------------------------------------------------------------------------- 1 | import { AccessError } from "@/features/waiting-room/error/AccessError"; 2 | 3 | async function validateAccess(roomId: string, signal: AbortSignal) { 4 | try { 5 | const [tokenResponse, roomResponse] = await Promise.all([ 6 | fetch("/api/users/token", { signal }), 7 | fetch(`/api/betrooms/${roomId}`, { signal }), 8 | ]); 9 | 10 | if (!tokenResponse.ok) { 11 | throw AccessError.unauthorized("토큰이 존재하지 않습니다.", { 12 | requiredRole: "user", 13 | }); 14 | } 15 | if (!roomResponse.ok) { 16 | throw AccessError.forbidden("방에 접근할 수 없습니다.", { 17 | roomId, 18 | }); 19 | } 20 | } catch (error) { 21 | if (error instanceof AccessError) { 22 | throw error; 23 | } 24 | if ((error as Error).name === "AbortError") { 25 | throw new Error("요청이 취소되었습니다."); 26 | } 27 | throw new Error("알 수 없는 오류가 발생했습니다."); 28 | } 29 | } 30 | 31 | export { validateAccess }; 32 | -------------------------------------------------------------------------------- /frontend/src/shared/misc.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | responseBetRoomInfo, 4 | responseUserInfoSchema, 5 | } from "@betting-duck/shared"; 6 | 7 | type UserInfo = z.infer; 8 | 9 | type RootLoaderData = { 10 | userInfo: UserInfo; 11 | }; 12 | 13 | const BettingRoomInfoSchema = responseBetRoomInfo.extend({ 14 | isPlaceBet: z.boolean(), 15 | placeBetAmount: z.number(), 16 | }); 17 | type BettingRoomInfo = z.infer; 18 | 19 | export { 20 | type UserInfo, 21 | type RootLoaderData, 22 | type BettingRoomInfo, 23 | BettingRoomInfoSchema, 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | VITE_SOCKET_URL: string; 5 | SOCKET_URL: string; 6 | VITE_APP_ENV: string; 7 | } 8 | 9 | interface ImportMeta { 10 | env: ImportMetaEnv; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/test/sum.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | 3 | export function sum(a: number, b: number) { 4 | return a + b; 5 | } 6 | 7 | test("adds 1 + 2 to equal 3", () => { 8 | expect(sum(1, 2)).toBe(3); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "paths": { 25 | "@/*": ["./src/*"], 26 | "@components/*": ["./src/components/*"], 27 | "@pages/*": ["./src/pages/*"], 28 | "@routes/*": ["./src/routes/*"], 29 | "@widgets/*": ["./src/widgets/*"], 30 | "@shared/*": ["./src/shared/*"], 31 | "@app/*": ["./src/app/*"], 32 | "@assets/*": ["./src/assets/*"] 33 | } 34 | }, 35 | "include": ["src", "main.js"] 36 | } 37 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /nginx.dev.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include mime.types; 9 | default_type application/octet-stream; 10 | 11 | # 업스트림 서버 정의 12 | upstream backend_servers { 13 | # 백엔드 서버 목록 (로드 밸런싱 대상 서버) 14 | server betting_duck_app_dev:3000; 15 | } 16 | 17 | server { 18 | listen 80; 19 | 20 | # 일반 API 요청 처리 21 | location /api { 22 | proxy_pass http://backend_servers/api; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-Forwarded-Proto $scheme; 27 | } 28 | 29 | # Socket.IO 요청 처리 30 | location /socket.io/ { 31 | proxy_pass http://backend_servers/socket.io/; 32 | proxy_http_version 1.1; 33 | proxy_set_header Upgrade $http_upgrade; 34 | proxy_set_header Connection $http_connection; 35 | proxy_set_header Host $host; 36 | proxy_set_header X-Real-IP $remote_addr; 37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 38 | proxy_set_header X-Forwarded-Proto $scheme; 39 | 40 | # WebSocket 시간 초과 설정 41 | proxy_read_timeout 60s; 42 | proxy_send_timeout 60s; 43 | 44 | # 버퍼 크기 설정 45 | proxy_buffering off; 46 | } 47 | 48 | # 정적 파일 경로 설정 49 | location / { 50 | root /static/; 51 | index index.html; 52 | autoindex off; 53 | try_files $uri /index.html; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web14-boostproject", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "prepare": "husky", 9 | "lint": "eslint frontend/src", 10 | "dev": "pnpm -r dev" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@eslint/js": "^9.13.0", 17 | "eslint": "^9.13.0", 18 | "eslint-config-prettier": "^9.1.0", 19 | "eslint-plugin-import": "^2.31.0", 20 | "eslint-plugin-prettier": "^5.2.1", 21 | "globals": "^15.11.0", 22 | "husky": "^9.1.6", 23 | "lint-staged": "^15.2.10", 24 | "prettier": "^3.3.3", 25 | "prettier-plugin-tailwindcss": "^0.6.8", 26 | "typescript": "^5.6.3", 27 | "typescript-eslint": "^8.12.2" 28 | }, 29 | "lint-staged": { 30 | "frontend/src/**/*.{js,jsx,ts,tsx}": [ 31 | "eslint --fix", 32 | "prettier --write" 33 | ], 34 | "backend/src/**/*.{js,jsx,ts,tsx}": [ 35 | "eslint --fix", 36 | "prettier --write" 37 | ], 38 | "frontend/src/**/*.{json,css,md}": [ 39 | "prettier --write" 40 | ], 41 | "backend/src/**/*.{json,css,md}": [ 42 | "prettier --write" 43 | ], 44 | ".husky/**/*.js": [ 45 | "prettier --write" 46 | ] 47 | }, 48 | "dependencies": { 49 | "zod": "^3.23.8" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "frontend" 3 | - "backend" 4 | - "shared" 5 | -------------------------------------------------------------------------------- /redis.conf: -------------------------------------------------------------------------------- 1 | logfile /var/log/redis/redis-server.log 2 | maxmemory 1024mb 3 | maxmemory-policy volatile-lru 4 | lua-time-limit 5000 -------------------------------------------------------------------------------- /shared/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./schemas/bet"; 2 | export * from "./schemas/bettingRooms"; 3 | export * from "./schemas/betresult"; 4 | export * from "./schemas/users"; 5 | export * from "./vars"; 6 | export * from "./schemas/chat/socket/request"; 7 | export * from "./schemas/chat/socket/reponse"; 8 | -------------------------------------------------------------------------------- /shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@betting-duck/shared", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "zod": "^3.23.8" 14 | }, 15 | "peerDependencies": { 16 | "zod": "^3.23.8" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /shared/schemas/bet/index.ts: -------------------------------------------------------------------------------- 1 | import { betRequestSchema } from "./request"; 2 | import { betResponseSchema } from "./response"; 3 | import { 4 | fetchBetRoomInfoRequestSchema, 5 | fetchBetRoomInfoRequestType, 6 | joinBetRoomRequestSchema, 7 | joinBetRoomRequestType, 8 | } from "./socket/request"; 9 | import { 10 | responseFetchBetRoomInfoSchema, 11 | type responseFetchBetRoomInfoType, 12 | responseBetRoomInfo, 13 | channelSchema, 14 | } from "./socket/response"; 15 | 16 | export { 17 | channelSchema, 18 | responseBetRoomInfo, 19 | betRequestSchema, 20 | betResponseSchema, 21 | fetchBetRoomInfoRequestSchema, 22 | type fetchBetRoomInfoRequestType, 23 | joinBetRoomRequestSchema, 24 | type joinBetRoomRequestType, 25 | responseFetchBetRoomInfoSchema, 26 | responseFetchBetRoomInfoType, 27 | }; 28 | -------------------------------------------------------------------------------- /shared/schemas/bet/request.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { selectedOption } from "../../vars/selectedoption"; 3 | 4 | export const betRequestSchema = z.object({ 5 | bet_room_id: z.string(), 6 | bet_amount: z.number().int().positive("배팅 금액은 양수여야 합니다."), 7 | selected_option: z.enum(selectedOption), 8 | }); 9 | -------------------------------------------------------------------------------- /shared/schemas/bet/response.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { StatusCodeSchema, StatusMessageSchema } from "../../vars/status"; 3 | 4 | export const betResponseSchema = z.object({ 5 | status: StatusCodeSchema, 6 | data: z.object({ 7 | message: StatusMessageSchema, 8 | }), 9 | }); 10 | -------------------------------------------------------------------------------- /shared/schemas/bet/socket/request.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { roomIdSchema } from "../../shared"; 3 | 4 | export const fetchBetRoomInfoRequestSchema = roomIdSchema; 5 | 6 | export type fetchBetRoomInfoRequestType = z.infer< 7 | typeof fetchBetRoomInfoRequestSchema 8 | >; 9 | 10 | export const joinBetRoomRequestSchema = z.object({ 11 | sender: z.object({ 12 | betAmount: z.number().min(0, "베팅 금액은 0 이상이어야 합니다."), 13 | selectOption: z.enum(["option1", "option2"], { 14 | message: "선택 옵션은 1 또는 2이어야 합니다.", 15 | }), 16 | }), 17 | channel: roomIdSchema, 18 | }); 19 | 20 | export type joinBetRoomRequestType = z.infer; 21 | -------------------------------------------------------------------------------- /shared/schemas/bet/socket/response.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { nicknameSchema } from "../../shared"; 3 | 4 | const responseFetchBetRoomInfoSchema = z.object({ 5 | channel: z.object({ 6 | id: z.string(), 7 | creator: nicknameSchema, 8 | status: z.enum(["waiting", "betting", "result"], { 9 | message: "status는 waiting, betting, result 중 하나여야 합니다.", 10 | }), 11 | option1: z.object({ 12 | participants: z.number(), 13 | currentBets: z.number(), 14 | }), 15 | option2: z.object({ 16 | participants: z.number(), 17 | currentBets: z.number(), 18 | }), 19 | }), 20 | }); 21 | 22 | const creatorSchema = z.object({ 23 | id: z.number().int().positive(), 24 | }); 25 | 26 | const optionSchema = z.object({ 27 | name: z.string(), 28 | }); 29 | 30 | const channelSchema = z.object({ 31 | id: z.string(), 32 | title: z.string(), 33 | creator: creatorSchema, 34 | options: z.object({ 35 | option1: optionSchema, 36 | option2: optionSchema, 37 | }), 38 | status: z.enum(["waiting", "active", "timeover", "finished"]), 39 | settings: z.object({ 40 | defaultBetAmount: z.number().int().positive(), 41 | duration: z.number().int().positive(), 42 | }), 43 | metadata: z.object({ 44 | createdAt: z.string().datetime(), 45 | startAt: z.string().datetime().nullable(), 46 | endAt: z.string().datetime().nullable(), 47 | }), 48 | urls: z.object({ 49 | invite: z.string().url(), 50 | }), 51 | isAdmin: z.boolean(), 52 | }); 53 | 54 | const responseBetRoomInfo = z.object({ 55 | channel: channelSchema, 56 | message: z.string(), 57 | }); 58 | 59 | type responseFetchBetRoomInfoType = z.infer< 60 | typeof responseFetchBetRoomInfoSchema 61 | >; 62 | 63 | export { 64 | channelSchema, 65 | responseFetchBetRoomInfoSchema, 66 | responseBetRoomInfo, 67 | type responseFetchBetRoomInfoType, 68 | }; 69 | -------------------------------------------------------------------------------- /shared/schemas/betresult/index.ts: -------------------------------------------------------------------------------- 1 | import { betResultResponseSchema } from "./response"; 2 | 3 | export { betResultResponseSchema }; 4 | -------------------------------------------------------------------------------- /shared/schemas/betresult/response.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { selectedOption } from "../../vars/selectedoption"; 3 | import { StatusMessageSchema } from "../../vars/status"; 4 | 5 | export const betResultResponseSchema = z.object({ 6 | option_1_total_bet: z 7 | .number() 8 | .int() 9 | .positive("옵션 1의 총 배팅 금액은 양수여야 합니다."), 10 | option_2_total_bet: z 11 | .number() 12 | .int() 13 | .positive("옵션 2의 총 배팅 금액은 양수여야 합니다."), 14 | option_1_total_participants: z 15 | .number() 16 | .int() 17 | .positive("옵션 1의 총 참여자 수는 양수여야 합니다."), 18 | option_2_total_participants: z 19 | .number() 20 | .int() 21 | .positive("옵션 2의 총 참여자 수는 양수여야 합니다."), 22 | winning_option: z.enum(selectedOption), 23 | message: StatusMessageSchema, 24 | }); 25 | -------------------------------------------------------------------------------- /shared/schemas/bettingRooms/index.ts: -------------------------------------------------------------------------------- 1 | import { requestCreateBetroomSchema } from "./request"; 2 | import { responseBetroomSchema } from "./response"; 3 | 4 | export { requestCreateBetroomSchema, responseBetroomSchema }; 5 | -------------------------------------------------------------------------------- /shared/schemas/bettingRooms/request.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const betroomsCommonSchema = z.object({ 4 | channel: z.object({ 5 | title: z.string().min(2, "방 제목은 2자 이상이어야 합니다."), 6 | }), 7 | }); 8 | 9 | export const requestCreateBetroomSchema = betroomsCommonSchema.extend({ 10 | options: z.object({ 11 | option_1: z.string().min(2, "옵션 1은 2자 이상이어야 합니다."), 12 | option_2: z.string().min(2, "옵션 2은 2자 이상이어야 합니다."), 13 | }), 14 | settings: z.object({ 15 | duration: z.number().int().positive("타이머는 양수여야 합니다."), 16 | defaultBetAmount: z.number().int().positive("타이머는 양수여야 합니다."), 17 | }), 18 | }); 19 | -------------------------------------------------------------------------------- /shared/schemas/bettingRooms/response.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { StatusCodeSchema, StatusMessageSchema } from "../../vars/status"; 3 | 4 | const validateUrl = (url: string) => { 5 | const urlRegex = /^(http|https):\/\/[^ "]+$/; 6 | return urlRegex.test(url); 7 | }; 8 | 9 | const responseCommonSchema = z.object({ 10 | status: StatusCodeSchema, 11 | data: z.object({ 12 | message: StatusMessageSchema, 13 | }), 14 | }); 15 | 16 | const betroomsCommonSchema = responseCommonSchema.extend({ 17 | data: z.object({ 18 | title: z.string().min(2, "방 제목은 2자 이상이어야 합니다."), 19 | url: z.string().refine(validateUrl, "URL 형식이 올바르지 않습니다."), 20 | }), 21 | }); 22 | 23 | export const responseBetroomSchema = betroomsCommonSchema.extend({ 24 | data: z.object({ 25 | bet_room_id: z.string(), 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /shared/schemas/chat/socket/reponse.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { nicknameSchema, messageSchema } from "../../shared"; 3 | 4 | export const messageResponseSchema = z.object({ 5 | sender: nicknameSchema, 6 | message: messageSchema, 7 | }); 8 | -------------------------------------------------------------------------------- /shared/schemas/chat/socket/request.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { nicknameSchema, roomIdSchema, messageSchema } from "../../shared"; 3 | 4 | export const sendMessageRequestSchema = z.object({ 5 | sender: nicknameSchema, 6 | message: messageSchema, 7 | channel: roomIdSchema, 8 | }); 9 | 10 | export type sendMessageRequestType = z.infer; 11 | -------------------------------------------------------------------------------- /shared/schemas/shared.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const roomIdSchema = z.object({ 4 | roomId: z.string().min(1, "Room ID가 필요합니다."), 5 | }); 6 | 7 | export const nicknameSchema = z.object({ 8 | nickname: z.string().min(1, "닉네임이 필요합니다."), 9 | }); 10 | 11 | export const messageSchema = z.string().min(1, "메시지가 필요합니다."); 12 | 13 | export const joinRoomRequestSchema = z.object({ 14 | channel: roomIdSchema, 15 | }); 16 | 17 | export type joinRoomRequestType = z.infer; 18 | 19 | export const leaveRoomRequestSchema = roomIdSchema; 20 | 21 | export type leaveRoomRequestType = z.infer; 22 | -------------------------------------------------------------------------------- /shared/schemas/users/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | responseUsersSchema, 3 | rejectResponseSchema, 4 | responseGuestLoginSchema, 5 | responseUserInfoSchema, 6 | } from "./response"; 7 | 8 | export { 9 | responseUsersSchema, 10 | rejectResponseSchema, 11 | responseGuestLoginSchema, 12 | responseUserInfoSchema, 13 | }; 14 | -------------------------------------------------------------------------------- /shared/schemas/users/request.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const passwordStrength = (password: string) => { 4 | const hasNumber = /\d/.test(password); 5 | const hasLower = /[a-z]/.test(password); 6 | 7 | return hasNumber && hasLower; 8 | }; 9 | 10 | const userCommonSchema = z.object({ 11 | nickname: z.string().min(1, "닉네임은 1자 이상이어야 합니다."), 12 | password: z 13 | .string() 14 | .refine( 15 | passwordStrength, 16 | "비밀번호는 영문 소문자, 숫자를 포함해야 합니다.", 17 | ), 18 | }); 19 | 20 | export const requestSignUpSchema = userCommonSchema.extend({ 21 | email: z 22 | .string() 23 | .email("이메일 형식이여야 합니다.") 24 | .min(6, "이메일은 6자 이상이어야 합니다."), 25 | }); 26 | 27 | export const requestSignInSchema = z.object({ 28 | email: z 29 | .string() 30 | .email("이메일 형식이여야 합니다.") 31 | .min(6, "이메일은 6자 이상이어야 합니다."), 32 | password: z.string().min(1, "비밀번호는 1자 이상이어야 합니다."), 33 | }); 34 | 35 | export const requestGuestSignInSchema = z.object({ 36 | nickname: z.string().min(1, "닉네임은 1자 이상이어야 합니다."), 37 | }); 38 | 39 | export const requestGuestLoginActivitySchema = z.object({}); 40 | 41 | export const requestUpgradeGuest = requestSignUpSchema; 42 | -------------------------------------------------------------------------------- /shared/schemas/users/response.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { USER_ROLE } from "../../vars/user"; 3 | import { StatusCodeSchema, StatusMessageSchema } from "../../vars/status"; 4 | 5 | const commonUserSchema = z.object({ 6 | status: StatusCodeSchema, 7 | data: z.object({ 8 | message: StatusMessageSchema, 9 | }), 10 | }); 11 | 12 | export const responseSignUpSchema = commonUserSchema; 13 | 14 | export const responseSignInSchema = commonUserSchema.extend({ 15 | data: z.object({ 16 | role: z.enum(USER_ROLE), 17 | accessToken: z.string(), 18 | nickname: z.string().min(2, "닉네임은 2자 이상이어야 합니다."), 19 | }), 20 | }); 21 | 22 | export const responseGuestLoginSchema = responseSignInSchema; 23 | 24 | export const responseUsersSchema = commonUserSchema.extend({ 25 | nickename: z.string().min(2, "닉네임은 2자 이상이어야 합니다."), 26 | duck: z.number(), 27 | }); 28 | 29 | export const responseUserInfoSchema = z.object({ 30 | message: StatusMessageSchema, 31 | role: z.enum(USER_ROLE), 32 | nickname: z.string().min(2, "닉네임은 2자 이상이어야 합니다."), 33 | duck: z.coerce.number().int().nonnegative(), 34 | realDuck: z.coerce.number().int().nonnegative(), 35 | }); 36 | 37 | export const rejectResponseSchema = commonUserSchema; 38 | -------------------------------------------------------------------------------- /shared/vars/index.ts: -------------------------------------------------------------------------------- 1 | import { selectedOption } from "./selectedoption"; 2 | import { 3 | STATUS_CODE, 4 | STATUS_MAP, 5 | STATUS_MESSAGE, 6 | StatusCodeSchema, 7 | StatusMessageSchema, 8 | } from "./status"; 9 | import { USER_ROLE } from "./user"; 10 | 11 | export { 12 | selectedOption, 13 | STATUS_CODE, 14 | STATUS_MAP, 15 | STATUS_MESSAGE, 16 | StatusCodeSchema, 17 | StatusMessageSchema, 18 | USER_ROLE, 19 | }; 20 | -------------------------------------------------------------------------------- /shared/vars/selectedoption.ts: -------------------------------------------------------------------------------- 1 | export const selectedOption = ["option1", "option2"] as const; 2 | export type SelectedOption = (typeof selectedOption)[number]; 3 | -------------------------------------------------------------------------------- /shared/vars/status.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const STATUS_CODE = ["200", "204", "400", "401", "409", "500"] as const; 4 | export const STATUS_MESSAGE = [ 5 | "OK", 6 | "입력 오류(ID 중복검사, 로그인 등)", 7 | "올바르지 않은 형식입니다.", 8 | "토큰 만료", 9 | "이미 등록된 이메일입니다.", 10 | "DB에러(데이터 x, 서버 error)", 11 | ] as const; 12 | 13 | export const STATUS_MAP = STATUS_CODE.reduce( 14 | (acc, code, index) => { 15 | acc[code] = STATUS_MESSAGE[index]; 16 | return acc; 17 | }, 18 | {} as Record<(typeof STATUS_CODE)[number], (typeof STATUS_MESSAGE)[number]>, 19 | ); 20 | 21 | export type StatusCode = typeof STATUS_CODE; 22 | export type StatusMessage = typeof STATUS_MESSAGE; 23 | export type StatusMap = typeof STATUS_MAP; 24 | 25 | export const StatusCodeSchema = z.enum(STATUS_CODE); 26 | export const StatusMessageSchema = z.enum(STATUS_MESSAGE); 27 | -------------------------------------------------------------------------------- /shared/vars/user.ts: -------------------------------------------------------------------------------- 1 | export const USER_ROLE = ["user", "guest", "admin"] as const; 2 | export type UserRole = (typeof USER_ROLE)[number]; 3 | --------------------------------------------------------------------------------