diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a27b2e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +.pnpm-store + +# Lockfile backups +pnpm-lock.yaml.bak + +# Build outputs +dist +build +.next +.turbo + +# Git +.git +.gitignore + +# Environment +.env* +!.env.example + +# Docker +.dockerignore +**/Dockerfile +docker-compose.yml + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# System Files +.DS_Store +Thumbs.db + +# Testing +coverage + +# Cache +.eslintcache \ No newline at end of file diff --git a/.github/workflows/backend-deploy-do.yml b/.github/workflows/backend-deploy-do.yml new file mode 100644 index 0000000..7506a6f --- /dev/null +++ b/.github/workflows/backend-deploy-do.yml @@ -0,0 +1,88 @@ +# This workflow deploys the backend application to a DigitalOcean Droplet. +# It builds a simple Docker image (without PM2) and runs it on the server. + +name: Deploy Backend to Production (DigitalOcean - Simple) + +on: + push: + branches: + - main + paths: + - 'apps/api/**' + - 'packages/**' + - 'pnpm-lock.yaml' + - '.github/workflows/backend-deploy-do.yml' + - 'Docker/Dockerfile.api' + workflow_dispatch: # Allow manual triggering + +jobs: + deploy: + name: Build and Deploy Backend to DigitalOcean Droplet + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + # This should point to your simplified Dockerfile + file: ./Docker/Dockerfile.api + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/simcasino-api:latest + ${{ secrets.DOCKER_USERNAME }}/simcasino-api:${{ github.sha }} + + - name: Deploy to DigitalOcean Droplet + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DO_SSH_HOST }} + username: ${{ secrets.DO_SSH_USERNAME }} + key: ${{ secrets.DO_SSH_KEY }} + script: | + set -e + echo "🚀 Starting simple deployment on the server..." + + # 1. Pull the latest image + echo "1/3: Pulling latest Docker image..." + docker pull ${{ secrets.DOCKER_USERNAME }}/simcasino-api:latest + + # 2. Stop and remove the existing container + echo "2/3: Stopping and removing existing container..." + docker stop simcasino-api || true + docker rm simcasino-api || true + + # Run the new container with environment variables from GitHub Secrets + docker run -d \ + --name simcasino-api \ + --restart always \ + -p 5000:5000 \ + -e NODE_ENV=production \ + -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ + -e COOKIE_SECRET="${{ secrets.COOKIE_SECRET }}" \ + -e CORS_ORIGIN="${{ secrets.CORS_ORIGIN }}" \ + -e GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" \ + -e GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ + -e CLIENT_URL="${{ secrets.CLIENT_URL }}" \ + -e API_BASE_URL="${{ secrets.API_BASE_URL }}" \ + -e GOOGLE_CALLBACK_URL="${{ secrets.GOOGLE_CALLBACK_URL }}" \ + -e COOKIE_DOMAIN="${{ secrets.COOKIE_DOMAIN }}" \ + ${{ secrets.DOCKER_USERNAME }}/simcasino-api:latest + + - name: Deployment Notification + if: success() + run: | + echo "✅ Backend successfully deployed to AWS EC2." + echo " Commit: ${{ github.sha }}" + echo " Image Tag: ${{ secrets.DOCKER_USERNAME }}/simcasino-api:${{ github.sha }}" diff --git a/.github/workflows/backend-deploy.yml b/.github/workflows/backend-deploy.yml new file mode 100644 index 0000000..6079764 --- /dev/null +++ b/.github/workflows/backend-deploy.yml @@ -0,0 +1,78 @@ +name: Deploy Backend to Production (AWS EC2) + +on: + # push: + # branches: + # - main + # paths: + # - 'apps/api/**' + # - 'packages/**' + # - 'pnpm-lock.yaml' + # - '.github/workflows/backend-deploy.yml' + # - 'Docker/Dockerfile.api' + workflow_dispatch: # Allow manual triggering + +jobs: + deploy: + name: Build and Deploy Backend to AWS EC2 + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Docker/Dockerfile.api + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/simcasino-api:latest + ${{ secrets.DOCKER_USERNAME }}/simcasino-api:${{ github.sha }} + + - name: Deploy to AWS EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + script: | + # Pull the latest Docker image from Docker Hub + docker pull ${{ secrets.DOCKER_USERNAME }}/simcasino-api:latest + + # Stop and remove the existing container to prevent conflicts + # '|| true' ensures the script doesn't fail if the container doesn't exist + docker stop simcasino-api || true + docker rm simcasino-api || true + + # Run the new container with environment variables from GitHub Secrets + docker run -d \ + --name simcasino-api \ + --restart always \ + -p 5000:5000 \ + -e NODE_ENV=production \ + -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ + -e COOKIE_SECRET="${{ secrets.COOKIE_SECRET }}" \ + -e CORS_ORIGIN="${{ secrets.CORS_ORIGIN }}" \ + -e GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" \ + -e GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ + -e CLIENT_URL="${{ secrets.CLIENT_URL }}" \ + -e REDIRECT_URL="${{ secrets.REDIRECT_URL }}" \ + ${{ secrets.DOCKER_USERNAME }}/simcasino-api:latest + + - name: Deployment Notification + if: success() + run: | + echo "✅ Backend successfully deployed to AWS EC2." + echo " Commit: ${{ github.sha }}" + echo " Image Tag: ${{ secrets.DOCKER_USERNAME }}/simcasino-api:${{ github.sha }}" diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml new file mode 100644 index 0000000..3d611fe --- /dev/null +++ b/.github/workflows/frontend-deploy.yml @@ -0,0 +1,87 @@ +name: Deploy Frontend to S3 and CloudFront + +on: + push: + branches: + - dont-deploy + paths: + - 'apps/frontend/**' + - 'packages/**' + - 'pnpm-lock.yaml' + - '.github/workflows/frontend-deploy.yml' + workflow_dispatch: # Allow manual triggering + +env: + NODE_VERSION: 22.14.0 # Latest LTS version + PNPM_VERSION: 9.9.0 + +jobs: + build-and-deploy: + name: Build and Deploy Frontend + runs-on: ubuntu-latest + permissions: + id-token: write # Needed for AWS OIDC authentication + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install PNPM + run: npm install -g pnpm@${{ env.PNPM_VERSION }} + + - name: Setup Environment Variables + run: | + echo "VITE_APP_API_URL=${{ secrets.VITE_APP_API_URL }}" >> .env + echo "VITE_APP_VERSION=${GITHUB_SHA::7}" >> .env + echo "VITE_APP_ENVIRONMENT=production" >> .env + working-directory: ./apps/frontend + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Build Frontend + run: pnpm run build --filter=frontend + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Deploy to S3 + run: | + aws s3 sync ./apps/frontend/dist/ s3://${{ secrets.S3_BUCKET_NAME }}/ \ + --delete \ + --cache-control "max-age=31536000,public" \ + --exclude "*.html" \ + --exclude "robots.txt" \ + --exclude "sitemap.xml" + + # Upload HTML files with different cache settings + aws s3 sync ./apps/frontend/dist/ s3://${{ secrets.S3_BUCKET_NAME }}/ \ + --cache-control "max-age=0,no-cache,no-store,must-revalidate" \ + --content-type "text/html" \ + --exclude "*" \ + --include "*.html" \ + --include "robots.txt" \ + --include "sitemap.xml" + + - name: Invalidate CloudFront Distribution + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ + --paths "/*" + + - name: Deployment Notification + if: success() + run: | + echo "Frontend successfully deployed to production." + echo "Commit: ${GITHUB_SHA::7}" + echo "Branch: ${GITHUB_REF#refs/heads/}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4ddaaf3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Linting and Formatting the PR + +on: + pull_request: + branches: + - '**' + +jobs: + Continuous-Integration: + name: Performs linting and formatting on the PR + runs-on: ubuntu-latest + steps: + - name: Checkout the repo + uses: actions/checkout@v3 + - name: Setup pnpm + uses: pnpm/action-setup@v2 + - name: Install dependencies + run: pnpm install + - name: Lint + run: pnpm lint + - name: Format + run: pnpm format diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1a2be0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +.turbo +*.log +.next +dist +dist-ssr +*.local +.env +.cache +server/dist +public/dist diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..29dd12a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Run lint-staged to check staged files +npx lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..ded82e2 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8a9256f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "arrowParens": "avoid" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d44dba6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "always" + }, + "editor.formatOnSave": true, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ] +} diff --git a/BETTING_UTILITIES_GUIDE.md b/BETTING_UTILITIES_GUIDE.md new file mode 100644 index 0000000..b8ce922 --- /dev/null +++ b/BETTING_UTILITIES_GUIDE.md @@ -0,0 +1,254 @@ +# Betting Middleware and Utilities Integration Guide + +This document explains how to use the new centralized betting validation middleware and utilities across all game controllers. + +## Overview + +We've created centralized utilities to eliminate code duplication and standardize betting operations: + +1. **bet.utils.ts** - Core betting validation and transaction utilities +2. **bet.middleware.ts** - Express middleware for request validation +3. **game.utils.ts** - Game-specific utilities and base classes + +## Key Benefits + +- ✅ Eliminates duplicate validation logic across game controllers +- ✅ Standardized error handling and response formats +- ✅ Centralized bet amount and balance validation +- ✅ Consistent transaction handling with proper error rollback +- ✅ Type-safe game constraints validation + +## Implementation Pattern + +### 1. Router Setup + +```typescript +import { Router } from 'express'; +import { + validateBet, + validateGameConstraints, + requireAuth, +} from '../../../middlewares/bet.middleware'; + +const gameRouter: Router = Router(); + +gameRouter.post( + '/place-bet', + requireAuth, + validateBet({ + game: 'dice', // or 'keno', 'roulette', etc. + minBet: 0.01, + maxBet: 1000, + }), + validateGameConstraints({ + minBetAmount: 0.01, + maxBetAmount: 1000, + // Game-specific constraints: + allowedRiskLevels: ['low', 'medium', 'high'], // for keno + maxSelections: 10, // for keno/mines + minSelections: 1, + }), + placeBet +); +``` + +### 2. Controller Implementation + +```typescript +import { validateAndCreateBet } from '../../../utils/bet.utils'; +import { + formatGameResponse, + validateGameInput, +} from '../../../utils/game.utils'; + +export const placeBet = async (req: Request, res: Response) => { + const { betAmount /* game-specific params */ } = req.body; + + // Get validated data from middleware + const validatedBet = req.validatedBet!; + const userInstance = validatedBet.userInstance; + + // Validate game-specific input + validateGameInput('dice', { target, condition }); + + // Generate game result (game-specific logic) + const result = getGameResult({ userInstance /* params */ }); + + // Calculate payout + const payoutInCents = + result.payoutMultiplier > 0 + ? Math.round(validatedBet.betAmountInCents * result.payoutMultiplier) + : 0; + + // Create bet transaction + const transaction = await validateAndCreateBet({ + betAmount, + userId: (req.user as User).id, + game: 'dice', + gameState: result.state, + payoutAmount: payoutInCents / 100, + active: false, + }); + + // Format and send response + const response = formatGameResponse( + { + gameState: result.state, + payout: payoutInCents / 100, + payoutMultiplier: result.payoutMultiplier, + won: result.payoutMultiplier > 0, + }, + transaction, + betAmount + ); + + res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + ...result, + balance: response.balance, + id: response.id, + payout: response.payout, + }) + ); +}; +``` + +## Available Utilities + +### bet.utils.ts + +- `validateBetAmount()` - Validates bet amount and user balance +- `createBetTransaction()` - Creates bet record and updates balance in transaction +- `validateAndCreateBet()` - Combined validation and transaction creation + +### bet.middleware.ts + +- `requireAuth` - Ensures user authentication +- `validateBet(options)` - Validates bet amount and balance +- `validateGameConstraints(constraints)` - Validates game-specific constraints +- `validateSchema(schema)` - Validates request body against Zod schema + +### game.utils.ts + +- `BaseGameService` - Abstract base class for game services +- `createGameConstraints(game)` - Factory for game-specific constraints +- `validateGameInput(game, input)` - Game-specific input validation +- `formatGameResponse()` - Standardized response formatting + +## Game-Specific Constraints + +### Dice + +```typescript +{ + minBetAmount: 0.01, + maxBetAmount: 1000, +} +``` + +### Keno + +```typescript +{ + minBetAmount: 0.01, + maxBetAmount: 1000, + allowedRiskLevels: ['low', 'medium', 'high'], + maxSelections: 10, + minSelections: 1, +} +``` + +### Mines + +```typescript +{ + minBetAmount: 0.01, + maxBetAmount: 1000, + maxSelections: 24, + minSelections: 1, +} +``` + +### Roulette + +```typescript +{ + minBetAmount: 0.01, + maxBetAmount: 500, +} +``` + +### Blackjack + +```typescript +{ + minBetAmount: 0.01, + maxBetAmount: 1000, +} +``` + +## Migration Steps + +### For Each Game Controller: + +1. **Update Router** + + - Add middleware imports + - Apply `requireAuth`, `validateBet`, `validateGameConstraints` + - Remove manual authentication checks + +2. **Refactor Controller** + + - Remove manual bet validation logic + - Use `req.validatedBet` for validated data + - Replace transaction logic with `validateAndCreateBet()` + - Use `formatGameResponse()` for consistent responses + +3. **Remove Duplicate Code** + - Delete manual balance checks + - Remove duplicate transaction logic + - Remove manual error handling for common cases + +## Error Handling + +All utilities throw standardized errors: + +- `BadRequestError` - For validation failures +- `UnAuthenticatedError` - For authentication issues +- Database errors are properly rolled back in transactions + +## Type Safety + +All utilities are fully typed with TypeScript: + +- Request/Response types +- Game enums from Prisma +- Validated bet data structures +- Error response formats + +## Testing + +Each utility function can be tested independently: + +```typescript +// Example test +const result = await validateBetAmount({ + betAmount: 10, + userId: 'user-id', + minBet: 1, + maxBet: 100, +}); + +expect(result.betAmountInCents).toBe(1000); +``` + +## Performance Benefits + +- Reduced database queries through optimized transactions +- Cached user instances in middleware +- Single validation pass for all bet constraints +- Efficient balance calculations in cents (avoiding floating point issues) + +--- + +This refactoring significantly improves code maintainability, reduces bugs, and makes adding new games much easier. diff --git a/Docker/Dockerfile.api b/Docker/Dockerfile.api new file mode 100644 index 0000000..3a6561d --- /dev/null +++ b/Docker/Dockerfile.api @@ -0,0 +1,35 @@ +FROM node:22-slim + +# 🛠 Install OpenSSL 1.1 (needed by Prisma) and other required packages +RUN apt-get update && apt-get install -y \ + openssl \ + libssl-dev \ + libstdc++6 \ + zlib1g \ + bash \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g pnpm@9.9.0 + +WORKDIR /usr/src/app + +COPY ./packages ./packages +COPY ./pnpm-lock.yaml ./pnpm-lock.yaml +COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml + +COPY ./package.json ./package.json +COPY ./tsconfig.json ./tsconfig.json +COPY ./turbo.json ./turbo.json + +COPY ./apps/api ./apps/api + +RUN pnpm install --frozen-lockfile + +# Generate Prisma client +RUN pnpm db:generate + +RUN pnpm build --filter=api + +WORKDIR /usr/src/app/apps/api + +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..86b80b2 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Turborepo kitchen sink starter + +This is an official starter Turborepo with multiple meta-frameworks all working in harmony and sharing packages. + +This example also shows how to use [Workspace Configurations](https://turbo.build/repo/docs/core-concepts/monorepos/configuring-workspaces). + +## Using this example + +Run the following command: + +```sh +npx create-turbo@latest -e kitchen-sink +``` + +## What's inside? + +This Turborepo includes the following packages and apps: + +### Apps and Packages + +- `api`: an [Express](https://expressjs.com/) server +- `storefront`: a [Next.js](https://nextjs.org/) app +- `admin`: a [Vite](https://vitejs.dev/) single page app +- `blog`: a [Remix](https://remix.run/) blog +- `@repo/eslint-config`: ESLint configurations used throughout the monorepo +- `@repo/jest-presets`: Jest configurations +- `@repo/logger`: isomorphic logger (a small wrapper around console.log) +- `@repo/ui`: a dummy React UI library (which contains `` and `` components) +- `@repo/typescript-config`: tsconfig.json's used throughout the monorepo + +Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Utilities + +This Turborepo has some additional tools already setup for you: + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Jest](https://jestjs.io) test runner for all things JavaScript +- [Prettier](https://prettier.io) for code formatting diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..4451a71 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,12 @@ +PORT=5000 + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +CLIENT_URL=http://localhost:3000 +REDIRECT_URL=/api/v1/auth/google/callback + +COOKIE_SECRET=secret + +NODE_ENV=development + +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js new file mode 100644 index 0000000..cf2a84b --- /dev/null +++ b/apps/api/.eslintrc.js @@ -0,0 +1,12 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: ['@repo/eslint-config/server.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + }, + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-misused-promises': 'off', + }, +}; diff --git a/apps/api/dice.svg b/apps/api/dice.svg new file mode 100644 index 0000000..3a84b41 --- /dev/null +++ b/apps/api/dice.svg @@ -0,0 +1 @@ +2Artboard 440 \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..0834e9f --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,60 @@ +{ + "name": "api", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "node dist/index.js", + "dev": "tsup --watch --onSuccess \"node dist/index.js\"", + "build": "tsup", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "test": "jest --detectOpenHandles" + }, + "jest": { + "preset": "@repo/jest-presets/node" + }, + "dependencies": { + "@prisma/client": "5.19.0", + "bcrypt": "^5.1.1", + "body-parser": "^1.20.2", + "cors": "^2.8.5", + "ioredis": "5.7.0", + "dotenv": "^16.4.5", + "express": "^4.18.3", + "express-async-errors": "^3.1.1", + "express-session": "^1.18.0", + "http-status-codes": "^2.3.0", + "lodash": "^4.17.21", + "morgan": "^1.10.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-local": "^1.0.0", + "express-rate-limit": "8.1.0", + "rate-limit-redis": "4.2.2" + }, + "devDependencies": { + "@repo/common": "workspace:*", + "@repo/db": "workspace:*", + "@repo/eslint-config": "workspace:*", + "@repo/jest-presets": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/bcrypt": "^5.0.2", + "@types/body-parser": "^1.19.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", + "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.7", + "@types/morgan": "^1.9.9", + "@types/node": "^20.11.24", + "@types/passport": "^1.0.16", + "@types/passport-google-oauth20": "^2.0.16", + "@types/passport-local": "^1.0.38", + "@types/supertest": "^6.0.2", + "jest": "^29.7.0", + "supertest": "7.1.3", + "tsup": "^8.0.2", + "typescript": "^5.3.3" + } +} diff --git a/apps/api/src/__tests__/server.test.ts b/apps/api/src/__tests__/server.test.ts new file mode 100644 index 0000000..f3a642a --- /dev/null +++ b/apps/api/src/__tests__/server.test.ts @@ -0,0 +1,22 @@ +import supertest from 'supertest'; +import { createServer } from '../server'; + +describe('Server', () => { + it('health check returns 200', async () => { + await supertest(createServer()) + .get('/status') + .expect(200) + .then(res => { + expect(res.ok).toBe(true); + }); + }); + + it('message endpoint says hello', async () => { + await supertest(createServer()) + .get('/message/jared') + .expect(200) + .then(res => { + expect(res.body).toEqual({ message: 'hello jared' }); + }); + }); +}); diff --git a/apps/api/src/config/passport.ts b/apps/api/src/config/passport.ts new file mode 100644 index 0000000..70c6453 --- /dev/null +++ b/apps/api/src/config/passport.ts @@ -0,0 +1,103 @@ +import passport from 'passport'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Strategy as LocalStrategy } from 'passport-local'; +import { compare } from 'bcrypt'; +import db from '@repo/db'; +import type { User } from '@prisma/client'; + +passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID || '', + clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + callbackURL: process.env.GOOGLE_CALLBACK_URL || `${process.env.API_BASE_URL || 'http://localhost:5000'}/api/v1/auth/google/callback`, + }, + async (_, __, profile, done) => { + const profileInfo = { + googleId: profile.id, + name: profile.displayName, + picture: profile.photos?.[0].value || null, + }; + + try { + console.log('Processing Google profile:', { + id: profile.id, + email: profile.emails?.[0].value, + name: profile.displayName, + }); + + const email = profile.emails?.[0].value; + if (!email) { + console.error('No email provided by Google OAuth'); + return done(new Error('No email provided by Google'), undefined); + } + + // Find the user by email + let user = await db.user.findFirst({ + where: { email }, + }); + + if (!user) { + console.log('Creating new user for email:', email); + user = await db.user.create({ + data: { + ...profileInfo, + email, + }, + }); + } else { + console.log('Updating existing user:', user.id); + user = await db.user.update({ + where: { id: user.id }, + data: profileInfo, + }); + } + + console.log('User authentication successful:', user.id); + done(null, user); + } catch (err) { + console.error('Google OAuth processing error:', err); + done(err, undefined); + } + } + ) +); + +passport.use( + new LocalStrategy( + { + usernameField: 'email', + }, + async (email, password, done) => { + try { + const user = await db.user.findUnique({ + where: { email }, + }); + + if (!user || !(await compare(password, user.password || ''))) { + done(null, false, { message: 'Invalid email or password' }); + return; + } + + done(null, user); + } catch (err) { + done(err, undefined); + } + } + ) +); + +passport.serializeUser((user: Express.User, done) => { + done(null, (user as User).id); +}); + +passport.deserializeUser(async (id: string, done) => { + try { + const user = await db.user.findUnique({ + where: { id }, + }); + done(null, user); + } catch (err) { + done(err, null); + } +}); diff --git a/apps/api/src/errors/bad-request.ts b/apps/api/src/errors/bad-request.ts new file mode 100644 index 0000000..8acc12f --- /dev/null +++ b/apps/api/src/errors/bad-request.ts @@ -0,0 +1,12 @@ +import { StatusCodes } from 'http-status-codes'; +import CustomAPIError from './custom-api'; + +class BadRequestError extends CustomAPIError { + statusCode: StatusCodes; + constructor(message: string) { + super(message); + this.statusCode = StatusCodes.BAD_REQUEST; + } +} + +export default BadRequestError; diff --git a/apps/api/src/errors/custom-api.ts b/apps/api/src/errors/custom-api.ts new file mode 100644 index 0000000..c52f62c --- /dev/null +++ b/apps/api/src/errors/custom-api.ts @@ -0,0 +1,3 @@ +class CustomAPIError extends Error {} + +export default CustomAPIError; diff --git a/apps/api/src/errors/index.ts b/apps/api/src/errors/index.ts new file mode 100644 index 0000000..1ec8aee --- /dev/null +++ b/apps/api/src/errors/index.ts @@ -0,0 +1,5 @@ +import BadRequestError from './bad-request'; +import NotFoundError from './not-found'; +import UnAuthenticatedError from './unauthenticated'; + +export { BadRequestError, NotFoundError, UnAuthenticatedError }; diff --git a/apps/api/src/errors/not-found.ts b/apps/api/src/errors/not-found.ts new file mode 100644 index 0000000..dd8865e --- /dev/null +++ b/apps/api/src/errors/not-found.ts @@ -0,0 +1,12 @@ +import { StatusCodes } from 'http-status-codes'; +import CustomAPIError from './custom-api'; + +class NotFoundError extends CustomAPIError { + statusCode: StatusCodes; + constructor(message: string) { + super(message); + this.statusCode = StatusCodes.NOT_FOUND; + } +} + +export default NotFoundError; diff --git a/apps/api/src/errors/unauthenticated.ts b/apps/api/src/errors/unauthenticated.ts new file mode 100644 index 0000000..fbaf366 --- /dev/null +++ b/apps/api/src/errors/unauthenticated.ts @@ -0,0 +1,12 @@ +import { StatusCodes } from 'http-status-codes'; +import CustomAPIError from './custom-api'; + +class UnAuthenticatedError extends CustomAPIError { + statusCode: StatusCodes; + constructor(message: string) { + super(message); + this.statusCode = StatusCodes.UNAUTHORIZED; + } +} + +export default UnAuthenticatedError; diff --git a/apps/api/src/features/auth/auth.router.ts b/apps/api/src/features/auth/auth.router.ts new file mode 100644 index 0000000..8182533 --- /dev/null +++ b/apps/api/src/features/auth/auth.router.ts @@ -0,0 +1,139 @@ +import passport from 'passport'; +import { hash } from 'bcrypt'; +import db from '@repo/db'; +import type { User } from '@prisma/client'; +import { StatusCodes } from 'http-status-codes'; +import { ApiResponse } from '@repo/common/types'; +import { Router } from 'express'; +import type { RequestHandler } from 'express'; +import { BadRequestError } from '../../errors'; +import { isAuthenticated } from '../../middlewares/auth.middleware'; + +interface RegisterRequestBody { + email: string; + password: string; + name: string; +} + +const router: Router = Router(); + +// Google authentication routes +router.get('/google', (req, res, next) => { + try { + // Get redirect URL from query parameter or use default + const redirectUrl = + (req.query.redirect_to as string) || process.env.CLIENT_URL; + const state = JSON.stringify({ redirect: redirectUrl }); + + console.log('Initiating Google OAuth with redirect:', redirectUrl); + + ( + passport.authenticate('google', { + scope: ['profile', 'email'], + state: encodeURIComponent(state), + }) as RequestHandler + )(req, res, next); + } catch (error) { + console.error('Google OAuth initiation error:', error); + next(error); + } +}); + +router.get( + '/google/callback', + passport.authenticate('google', { + failureRedirect: `${process.env.CLIENT_URL}?error=auth_failed`, + }) as RequestHandler, + (req, res) => { + try { + console.log('Google authentication successful for user:', req.user); + + const state = req.query.state + ? (JSON.parse(decodeURIComponent(req.query.state as string)) as { + redirect?: string; + }) + : {}; + + const redirectUrl = state.redirect || `${process.env.CLIENT_URL}`; + console.log('Redirecting to:', redirectUrl); + + res.redirect(redirectUrl); + } catch (error) { + console.error('Google callback processing error:', error); + res.redirect(`${process.env.CLIENT_URL}?error=callback_failed`); + } + } +); + +// // Local authentication routes +// router.post( +// '/login', +// passport.authenticate('local', { +// failureRedirect: `${process.env.CLIENT_URL}/login`, +// }) as RequestHandler, +// (req, res) => { +// res.redirect(`${process.env.CLIENT_URL}`); +// } +// ); + +// router.post('/register', async (req, res) => { +// const { email, password, name } = req.body as RegisterRequestBody; + +// const hashedPassword = await hash(password, 10); +// const user = await db.user.upsert({ +// where: { email }, +// update: { +// password: hashedPassword, +// name, +// }, +// create: { +// email, +// password: hashedPassword, +// name, +// }, +// }); + +// req.login(user, err => { +// if (err) throw new BadRequestError('Error logging in'); +// res.redirect(`${process.env.CLIENT_URL}`); +// }); +// }); + +router.get('/logout', (req, res, next) => { + req.logout(err => { + if (err) { + console.log('Logout error:', err); + return next(err); + } + + // Destroy the session + req.session.destroy(sessionErr => { + if (sessionErr) { + console.log('Session destroy error:', sessionErr); + return next(sessionErr); + } + + // Clear the session cookie + res.clearCookie('connect.sid', { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + }); + + res.json({ message: 'Logged out successfully' }); + }); + }); +}); + +router.get('/me', isAuthenticated, (req, res) => { + const user = req.user as User; + if (user.password) { + const { password: _password, ...userWithoutPassword } = user; + return res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, userWithoutPassword)); + } + return res.status(StatusCodes.OK).json(new ApiResponse(StatusCodes.OK, user)); +}); + +export default router; diff --git a/apps/api/src/features/games/bets/bets.controller.ts b/apps/api/src/features/games/bets/bets.controller.ts new file mode 100644 index 0000000..ccc6ef4 --- /dev/null +++ b/apps/api/src/features/games/bets/bets.controller.ts @@ -0,0 +1,28 @@ +import type { BetData, PaginatedBetData } from '@repo/common/types'; +import { ApiResponse } from '@repo/common/types'; +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { getBetById, getTopBets } from './bets.service'; + +export const getBets = async ( + req: Request, + res: Response> +) => { + // Get paginated bets + const paginatedBets = await getTopBets(); + + res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, paginatedBets)); +}; + +export const getBet = async ( + req: Request, + res: Response> +) => { + const { betId } = req.params; + + const bet = await getBetById(betId, req.isMyBet); + + res.status(StatusCodes.OK).json(new ApiResponse(StatusCodes.OK, { bet })); +}; diff --git a/apps/api/src/features/games/bets/bets.router.ts b/apps/api/src/features/games/bets/bets.router.ts new file mode 100644 index 0000000..d6fd81d --- /dev/null +++ b/apps/api/src/features/games/bets/bets.router.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { getBet, getBets } from './bets.controller'; +import { verifyMe } from '../../../middlewares/bet.middleware'; + +const betsRouter: Router = Router(); + +betsRouter.get('/', getBets); +betsRouter.get('/:betId', verifyMe, getBet); + +export default betsRouter; diff --git a/apps/api/src/features/games/bets/bets.service.ts b/apps/api/src/features/games/bets/bets.service.ts new file mode 100644 index 0000000..b3868c0 --- /dev/null +++ b/apps/api/src/features/games/bets/bets.service.ts @@ -0,0 +1,94 @@ +import { BetData, PaginatedBetData } from '@repo/common/types'; +import db from '@repo/db'; +import { NotFoundError } from '../../../errors'; + +export const getTopBets = async () => { + // Get paginated bets + const bets = await db.bet.findMany({ + orderBy: { + createdAt: 'desc', + }, + where: { active: false }, + include: { + user: { + select: { + id: true, + name: true, + }, + }, + }, + take: 10, + }); + + return { + bets: bets.map(bet => ({ + // Format betId as a 12-digit string with leading zeros + betId: bet.betId.toString().padStart(12, '0'), + game: bet.game, + date: bet.createdAt, + betAmount: bet.betAmount / 100, + payoutMultiplier: bet.payoutAmount / bet.betAmount, + payout: bet.payoutAmount / 100, + id: bet.id, + user: { + id: bet.user.id, + name: bet.user.name, + }, + })), + }; +}; + +export const getBetById = async ( + betId: string, + isMyBet: boolean = false +): Promise => { + const bet = await db.bet.findUnique({ + where: { + betId: Number(betId), + }, + include: { + user: { + select: { + id: true, + name: true, + }, + }, + provablyFairState: { + select: { + serverSeed: true, + clientSeed: true, + nonce: true, + hashedServerSeed: true, + revealed: true, + }, + }, + }, + }); + + if (!bet) { + throw new NotFoundError(`Bet with id ${betId} not found`); + } + + const { serverSeed, revealed, ...rest } = bet.provablyFairState; + + return { + betId: bet.betId.toString().padStart(12, '0'), + betNonce: bet.betNonce, + game: bet.game, + date: bet.createdAt, + gameState: bet.state, + betAmount: bet.betAmount / 100, + payoutMultiplier: bet.payoutAmount / bet.betAmount, + payout: bet.payoutAmount / 100, + id: bet.id, + provablyFairState: { + ...rest, + ...(revealed ? { serverSeed } : {}), + }, + user: { + id: bet.user.id, + name: bet.user.name, + }, + isMyBet, + }; +}; diff --git a/apps/api/src/features/games/blackjack/blackjack.controller.ts b/apps/api/src/features/games/blackjack/blackjack.controller.ts new file mode 100644 index 0000000..66b5776 --- /dev/null +++ b/apps/api/src/features/games/blackjack/blackjack.controller.ts @@ -0,0 +1,170 @@ +import type { User } from '@prisma/client'; +import { ApiResponse } from '@repo/common/types'; +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { + BlackjackBetSchema, + BlackjackPlayRoundSchema, +} from '@repo/common/game-utils/blackjack/validations.js'; +import type { + BlackjackActions, + BlackjackPlayRoundResponse, +} from '@repo/common/game-utils/blackjack/types.js'; +import db from '@repo/db'; +import { userManager } from '../../user/user.service'; +import { blackjackManager } from './blackjack.service'; +import { NotFoundError } from '../../../errors'; + +export const placeBet = async ( + req: Request, + res: Response | { message: string }> +) => { + const { betAmount } = validateBetRequest(req.body as { betAmount: number }); + const user = req.user as User; + + await validateActiveBet(user.id); + + const userInstance = await userManager.getUser(user.id); + + // Convert betAmount to cents for comparison and storage + const betAmountInCents = Math.round(betAmount * 100); + const userBalanceInCents = userInstance.getBalanceAsNumber(); + + if (userBalanceInCents < betAmountInCents) { + throw new Error('Insufficient balance'); + } + + const game = await blackjackManager.createGame({ + betAmount: betAmountInCents, // Pass amount in cents + userId: user.id, + }); + + const dbUpdateObject = game.getDbUpdateObject(); + + const payout = + dbUpdateObject && 'active' in dbUpdateObject.data + ? dbUpdateObject.data.payoutAmount + : 0; + + if (payout) { + blackjackManager.deleteGame(user.id); + } + + // Calculate new balance after deducting bet amount + const balanceChangeInCents = -betAmountInCents; // Negative because we're deducting + const newBalance = ( + userBalanceInCents + + balanceChangeInCents + + payout + ).toString(); + + // Update user balance in a transaction + await db.$transaction(async tx => { + if (dbUpdateObject) { + await tx.bet.update(dbUpdateObject); + } + await tx.user.update({ + where: { id: user.id }, + data: { + balance: newBalance, + }, + }); + }); + + // Update the user instance with the new balance + userInstance.setBalance(newBalance); + + res + .status(StatusCodes.OK) + .json( + new ApiResponse(StatusCodes.OK, game.getPlayRoundResponse(newBalance)) + ); +}; + +export const getActiveGame = async ( + req: Request, + res: Response | { message: string }> +) => { + const userId = (req.user as User).id; + const game = await blackjackManager.getGame(userId); + const userInstance = await userManager.getUser(userId); + + const balance = userInstance.getBalance(); + + if (!game || !game.getBet().active) { + throw new NotFoundError('Game not found'); + } + + return res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, game.getPlayRoundResponse(balance))); +}; + +export const blackjackNext = async ( + req: Request, + res: Response | { message: string }> +) => { + const { action } = validatePlayRequest(req.body as { action: string }); + const userId = (req.user as User).id; + const game = await blackjackManager.getGame(userId); + + if (!game?.getBet().active) { + throw new NotFoundError('Game not found'); + } + + const moneySpent = game.playRound(action as BlackjackActions); + const dbUpdateObject = game.getDbUpdateObject(); + + if (dbUpdateObject) { + if ('active' in dbUpdateObject.data) { + blackjackManager.deleteGame(userId); + } + await db.bet.update(dbUpdateObject); + } + + const userInstance = await userManager.getUser(userId); + const userBalanceInCents = userInstance.getBalanceAsNumber(); + + const newBalance = (userBalanceInCents + moneySpent).toString(); + + // Update user balance in a transaction + await db.$transaction(async tx => { + await tx.user.update({ + where: { id: userId }, + data: { + balance: newBalance, + }, + }); + }); + + userInstance.setBalance(newBalance); + + return res + .status(StatusCodes.OK) + .json( + new ApiResponse(StatusCodes.OK, game.getPlayRoundResponse(newBalance)) + ); +}; + +const validateBetRequest = (body: { betAmount: number }) => { + const result = BlackjackBetSchema.safeParse(body); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +}; + +const validateActiveBet = async (userId: string) => { + const game = await blackjackManager.getGame(userId); + if (game && game.getBet().active) { + throw new Error('You already have an active bet'); + } +}; + +const validatePlayRequest = (body: { action: string }) => { + const result = BlackjackPlayRoundSchema.safeParse(body); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +}; diff --git a/apps/api/src/features/games/blackjack/blackjack.router.ts b/apps/api/src/features/games/blackjack/blackjack.router.ts new file mode 100644 index 0000000..6b66467 --- /dev/null +++ b/apps/api/src/features/games/blackjack/blackjack.router.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../../../middlewares/auth.middleware'; +import { blackjackNext, getActiveGame, placeBet } from './blackjack.controller'; +import { rateLimitBets } from '../../../middlewares/rateLimit.middleware'; + +const blackjackRouter: Router = Router(); + +blackjackRouter.post( + '/bet', + isAuthenticated, + rateLimitBets({ maxBetsPerMinute: 30 }), + placeBet +); +blackjackRouter.post('/next', isAuthenticated, blackjackNext); +blackjackRouter.get('/active', isAuthenticated, getActiveGame); + +export default blackjackRouter; diff --git a/apps/api/src/features/games/blackjack/blackjack.service.ts b/apps/api/src/features/games/blackjack/blackjack.service.ts new file mode 100644 index 0000000..fb7f31a --- /dev/null +++ b/apps/api/src/features/games/blackjack/blackjack.service.ts @@ -0,0 +1,277 @@ +import type { Bet } from '@prisma/client'; +import db from '@repo/db'; +import { + convertFloatsToGameEvents, + determineWinner, + isActionValid, + getIsPlayerTurnOver, + playRoundAndUpdateState, + createInitialGameState, + getSafeGameState, +} from '@repo/common/game-utils/blackjack/utils.js'; +import type { + BlackjackActions, + BlackjackGameState, + BlackjackPlayRoundResponse, +} from '@repo/common/game-utils/blackjack/types.js'; +import type { UserInstance } from '../../user/user.service'; +import { userManager } from '../../user/user.service'; +import { InputJsonObject } from '@prisma/client/runtime/library'; + +interface GameCreationParams { + userId: string; + betAmount: number; +} + +class BlackjackManager { + private static instance: BlackjackManager | undefined; + private games = new Map(); + + private constructor() { + // Initialize any necessary state + } + + static getInstance() { + if (!BlackjackManager.instance) { + BlackjackManager.instance = new BlackjackManager(); + } + return BlackjackManager.instance; + } + + async getGame(userId: string): Promise { + if (this.games.has(userId)) { + return this.games.get(userId) || null; + } + + const bet = await this.findActiveBet(userId); + if (!bet) return null; + + const game = await this.createGameFromBet(bet); + this.games.set(userId, game); + return game; + } + + async createGame({ betAmount, userId }: GameCreationParams) { + const userInstance = await userManager.getUser(userId); + const gameEvents = this.generateGameEvents(userInstance); + + const bet = await this.createBetTransaction(userInstance, betAmount); + const game = new BlackjackGame({ bet, gameEvents, isNew: true }); + + this.games.set(bet.userId, game); + return game; + } + + private async findActiveBet(userId: string): Promise { + return db.bet.findFirst({ + where: { userId, active: true, game: 'blackjack' }, + }); + } + + private async createGameFromBet(bet: Bet): Promise { + const userInstance = await userManager.getUser(bet.userId); + const gameEvents = this.generateGameEvents(userInstance); + return new BlackjackGame({ bet, gameEvents, isNew: false }); + } + + private generateGameEvents(userInstance: UserInstance): number[] { + const floats = userInstance.generateFloats(52); + return convertFloatsToGameEvents(floats); + } + + private async createBetTransaction( + userInstance: UserInstance, + betAmount: number + ): Promise { + return db.$transaction(async tx => { + const bet = await tx.bet.create({ + data: { + active: true, + betAmount, + betNonce: userInstance.getNonce(), + game: 'blackjack', + provablyFairStateId: userInstance.getProvablyFairStateId(), + state: { actions: [['deal']] }, + userId: userInstance.getUser().id, + payoutAmount: 0, + }, + }); + await userInstance.updateNonce(tx); + return bet; + }); + } + + deleteGame(userId: string) { + this.games.delete(userId); + } +} + +class BlackjackGame { + private bet: Bet; + readonly gameEvents: number[]; + private amountMultiplier = 1; + private drawIndex = 4; // Start after initial deal + private gameState: BlackjackGameState; + private payout = 0; + private active = false; + + constructor({ + bet, + gameEvents, + isNew, + }: { + bet: Bet; + gameEvents: number[]; + isNew: boolean; + }) { + this.bet = bet; + this.gameEvents = gameEvents; + if (isNew) { + this.gameState = createInitialGameState(gameEvents); + } else { + this.gameState = bet.state as unknown as BlackjackGameState; + } + this.active = true; + if (this.isPlayerTurnComplete()) { + this.resolveGame(); + } + } + + getGameState() { + return this.gameState; + } + + getSafeGameState() { + return getSafeGameState(this.gameState); + } + + getBet() { + return this.bet; + } + + getAmountMultiplier(): number { + return this.amountMultiplier; + } + + playRound(action: BlackjackActions) { + this.validateAction(action); + const moneySpent = this.executeAction(action); + if (this.isPlayerTurnComplete()) { + this.resolveGame(); + return this.payout - moneySpent; + } + return -moneySpent; + } + + getDbUpdateObject(isGameCreate = false): null | { + where: { id: string }; + data: + | { + state: InputJsonObject; + } + | { + active: false; + payoutAmount: number; + state: InputJsonObject; + }; + } { + const playerActions = this.gameState.player.map(hand => hand.actions); + + if (isGameCreate && this.active) { + return null; + } + + if (this.active) { + return { + where: { id: this.bet.id }, + data: { + state: this.gameState as unknown as InputJsonObject, + }, + }; + } + return { + where: { id: this.bet.id }, + data: { + active: false, + payoutAmount: this.payout, + state: this.gameState as unknown as InputJsonObject, + }, + }; + } + + private validateAction(action: BlackjackActions): void { + if ( + !isActionValid({ gameState: this.gameState, action, active: this.active }) + ) { + throw new Error(`Invalid action: ${action}`); + } + } + + private executeAction(action: BlackjackActions): number { + const amountMultiplier = this.amountMultiplier; + const result = playRoundAndUpdateState({ + gameEvents: this.gameEvents, + drawIndex: this.drawIndex, + gameState: this.gameState, + action, + amountMultiplier, + }); + + this.drawIndex = result.drawIndex; + this.amountMultiplier = result.amountMultiplier || amountMultiplier; + + return this.bet.betAmount * (this.amountMultiplier - amountMultiplier); + } + + private isPlayerTurnComplete(): boolean { + return getIsPlayerTurnOver( + this.gameState.player.map(({ actions }) => actions) + ); + } + + getPlayRoundResponse(balance: string) { + if (!this.active) { + return this.constructGameOverResponse(balance); + } + return this.constructPlayRoundResponse(balance); + } + + private constructPlayRoundResponse( + balance: string + ): BlackjackPlayRoundResponse { + return { + id: this.bet.id, + active: this.active, + state: getSafeGameState(this.gameState), + betAmount: this.bet.betAmount / 100, // Convert back to dollars + amountMultiplier: this.amountMultiplier, + balance: Number(balance) / 100, + }; + } + + private constructGameOverResponse( + balance: string + ): BlackjackPlayRoundResponse { + return { + id: this.bet.id, + active: this.active, + state: this.gameState, + betAmount: this.bet.betAmount / 100, // Convert back to dollars + amountMultiplier: this.amountMultiplier, + payout: this.payout / 100, // Convert back to dollars + payoutMultiplier: + this.payout / (this.bet.betAmount * this.amountMultiplier), + balance: Number(balance) / 100, + }; + } + + private resolveGame(): void { + const payout = determineWinner(this.gameState, this.bet.betAmount); + + const finalAmount = this.bet.betAmount * this.amountMultiplier + payout; + this.payout = finalAmount; + this.active = false; + } +} + +export const blackjackManager = BlackjackManager.getInstance(); diff --git a/apps/api/src/features/games/dice/dice.controller.ts b/apps/api/src/features/games/dice/dice.controller.ts new file mode 100644 index 0000000..f8b72fa --- /dev/null +++ b/apps/api/src/features/games/dice/dice.controller.ts @@ -0,0 +1,72 @@ +import type { User } from '@prisma/client'; +import type { + DiceCondition, + DicePlaceBetResponse, +} from '@repo/common/game-utils/dice/types.js'; +import type { Request, Response } from 'express'; +import { ApiResponse } from '@repo/common/types'; +import { StatusCodes } from 'http-status-codes'; +import { getResult } from './dice.service'; +import { createBetTransaction, minorToAmount } from '../../../utils/bet.utils'; +import { + formatGameResponse, + validateGameInput, +} from '../../../utils/game.utils'; + +interface DiceRequestBody { + target: number; + condition: DiceCondition; + betAmount: number; +} + +export const placeBet = async ( + req: Request, + res: Response> +) => { + const { target, condition, betAmount } = req.body as DiceRequestBody; + + // Get validated bet data from middleware + const validatedBet = req.validatedBet!; + const { userInstance, betAmountInCents } = validatedBet; + + // Validate game-specific input + validateGameInput('dice', { target, condition }); + + // Generate game result + const result = getResult({ userInstance, target, condition }); + + // Calculate payout + const { payoutMultiplier } = result; + const payoutInCents = + payoutMultiplier > 0 ? Math.round(betAmountInCents * payoutMultiplier) : 0; + + // Create bet transaction using utility + const transaction = await createBetTransaction({ + betAmount: betAmountInCents, + userInstance, + game: 'dice', + gameState: result.state, + payoutAmount: payoutInCents, // Convert to dollars + active: false, + }); + + // Format and send response + const response = formatGameResponse( + { + gameState: result.state, + payout: minorToAmount(payoutInCents), + payoutMultiplier, + }, + transaction, + betAmount + ); + + res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + ...result, + balance: response.balance, + id: response.id, + payout: response.payout, + }) + ); +}; diff --git a/apps/api/src/features/games/dice/dice.router.ts b/apps/api/src/features/games/dice/dice.router.ts new file mode 100644 index 0000000..3c7afc1 --- /dev/null +++ b/apps/api/src/features/games/dice/dice.router.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { + validateBet, + validateGameConstraints, +} from '../../../middlewares/bet.middleware'; +import { placeBet } from './dice.controller'; +import { isAuthenticated } from '../../../middlewares/auth.middleware'; +import { rateLimitBets } from '../../../middlewares/rateLimit.middleware'; + +const diceRouter: Router = Router(); + +diceRouter.post( + '/place-bet', + isAuthenticated, + rateLimitBets({ maxBetsPerMinute: 30 }), + validateBet, + validateGameConstraints({ minBetAmount: 0.01 }), + placeBet +); + +export default diceRouter; diff --git a/apps/api/src/features/games/dice/dice.service.ts b/apps/api/src/features/games/dice/dice.service.ts new file mode 100644 index 0000000..94e311d --- /dev/null +++ b/apps/api/src/features/games/dice/dice.service.ts @@ -0,0 +1,53 @@ +import { + calculateMultiplier, + type DiceCondition, +} from '@repo/common/game-utils/dice/index.js'; +import type { UserInstance } from '../../user/user.service'; + +const getPayoutMultiplier = ({ + condition, + target, + result, +}: { + condition: DiceCondition; + target: number; + result: number; +}): number => { + const multiplier = calculateMultiplier(target, condition); + switch (condition) { + case 'above': + return result > target ? multiplier : 0; + case 'below': + return result < target ? multiplier : 0; + default: + return 0; + } +}; + +export const getResult = ({ + target, + condition, + userInstance, +}: { + target: number; + condition: DiceCondition; + userInstance: UserInstance; +}) => { + const [float] = userInstance.generateFloats(1); + const result = (float * 10001) / 100; // 0.00 to 100.00 + + const payoutMultiplier = getPayoutMultiplier({ + result, + condition, + target, + }); + + return { + state: { + target, + condition, + result: parseFloat(result.toFixed(2)), + }, + payoutMultiplier, + }; +}; diff --git a/apps/api/src/features/games/games.router.ts b/apps/api/src/features/games/games.router.ts new file mode 100644 index 0000000..fe7f24c --- /dev/null +++ b/apps/api/src/features/games/games.router.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import plinkooRouter from './plinkoo/plinkoo.router'; +import minesRouter from './mines/mines.router'; +import limboRouter from './limbo/limbo.router'; +import kenoRouter from './keno/keno.router'; +import diceRouter from './dice/dice.router'; +import rouletteRouter from './roulette/roulette.router'; +import blackjackRouter from './blackjack/blackjack.router'; +import betsRouter from './bets/bets.router'; + +const gameRouter: Router = Router(); + +gameRouter.use('/plinkoo', plinkooRouter); +gameRouter.use('/mines', minesRouter); +gameRouter.use('/limbo', limboRouter); +gameRouter.use('/keno', kenoRouter); +gameRouter.use('/dice', diceRouter); +gameRouter.use('/roulette', rouletteRouter); +gameRouter.use('/blackjack', blackjackRouter); +gameRouter.use('/bets', betsRouter); + +export default gameRouter; diff --git a/apps/api/src/features/games/keno/keno.controller.ts b/apps/api/src/features/games/keno/keno.controller.ts new file mode 100644 index 0000000..42f5f64 --- /dev/null +++ b/apps/api/src/features/games/keno/keno.controller.ts @@ -0,0 +1,59 @@ +import type { Request, Response } from 'express'; +import type { KenoResponse } from '@repo/common/game-utils/keno/types.js'; +import { KenoRequestSchema } from '@repo/common/game-utils/keno/types.js'; +import { StatusCodes } from 'http-status-codes'; +import { ApiResponse } from '@repo/common/types'; +import { BadRequestError } from '../../../errors'; +import { getResult } from './keno.service'; +import { createBetTransaction, minorToAmount } from '../../../utils/bet.utils'; +import { formatGameResponse } from '../../../utils/game.utils'; + +export const placeBet = async ( + req: Request, + res: Response> +) => { + const parsedRequest = KenoRequestSchema.safeParse(req.body); + if (!parsedRequest.success) { + throw new BadRequestError('Invalid request body'); + } + const { betAmount, selectedTiles, risk } = parsedRequest.data; + + const { betAmountInCents, userInstance } = req.validatedBet!; + + const result = getResult({ userInstance, selectedTiles, risk }); + + const { payoutMultiplier } = result; + + const payoutInCents = + payoutMultiplier > 0 ? Math.round(betAmountInCents * payoutMultiplier) : 0; + + const transaction = await createBetTransaction({ + betAmount: betAmountInCents, + userInstance, + game: 'keno', + gameState: result.state, + payoutAmount: payoutInCents, // Convert to dollars + active: false, + }); + + const response = formatGameResponse( + { + gameState: result.state, + payout: minorToAmount(payoutInCents), + payoutMultiplier, + }, + transaction, + betAmount + ); + + // Update balance and create bet in a single transaction + + res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + ...result, + balance: response.balance, + id: response.id, + payout: response.payout, + }) + ); +}; diff --git a/apps/api/src/features/games/keno/keno.router.ts b/apps/api/src/features/games/keno/keno.router.ts new file mode 100644 index 0000000..01ce094 --- /dev/null +++ b/apps/api/src/features/games/keno/keno.router.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../../../middlewares/auth.middleware'; +import { placeBet } from './keno.controller'; +import { validateBet } from '../../../middlewares/bet.middleware'; +import { rateLimitBets } from '../../../middlewares/rateLimit.middleware'; + +const kenoRouter: Router = Router(); + +kenoRouter.post( + '/place-bet', + isAuthenticated, + rateLimitBets({ maxBetsPerMinute: 30 }), + validateBet, + placeBet +); + +export default kenoRouter; diff --git a/apps/api/src/features/games/keno/keno.service.ts b/apps/api/src/features/games/keno/keno.service.ts new file mode 100644 index 0000000..ae04720 --- /dev/null +++ b/apps/api/src/features/games/keno/keno.service.ts @@ -0,0 +1,56 @@ +import type { KenoRisk } from '@repo/common/game-utils/keno/types.js'; +import { + NO_OF_TILES_KENO, + PAYOUT_MULTIPLIERS, +} from '@repo/common/game-utils/keno/constants.js'; +import { calculateSelectedGems } from '@repo/common/game-utils/keno/utils.js'; +import { convertFloatsToGameEvents } from '@repo/common/game-utils/mines/utils.js'; +import type { UserInstance } from '../../user/user.service'; + +const getPayoutMultiplier = ( + drawnNumbers: number[], + selectedTiles: number[], + risk: KenoRisk +) => { + const drawnNumbersSet = new Set(drawnNumbers); + let matches = 0; + for (const tile of selectedTiles) { + if (drawnNumbersSet.has(tile)) { + matches++; + } + } + return PAYOUT_MULTIPLIERS[risk][selectedTiles.length][matches]; +}; + +export const getResult = ({ + userInstance, + selectedTiles, + risk, +}: { + userInstance: UserInstance; + selectedTiles: number[]; + risk: KenoRisk; +}) => { + const floats = userInstance.generateFloats(10); + + const gameEvents = convertFloatsToGameEvents(floats, NO_OF_TILES_KENO); + + const drawnNumbers = calculateSelectedGems(gameEvents, 10).map( + num => num + 1 + ); + + const payoutMultiplier = getPayoutMultiplier( + drawnNumbers, + selectedTiles, + risk + ); + + return { + state: { + risk, + selectedTiles, + drawnNumbers, + }, + payoutMultiplier, + }; +}; diff --git a/apps/api/src/features/games/limbo/limbo.controller.ts b/apps/api/src/features/games/limbo/limbo.controller.ts new file mode 100644 index 0000000..8296540 --- /dev/null +++ b/apps/api/src/features/games/limbo/limbo.controller.ts @@ -0,0 +1,14 @@ +import type { Request, Response } from 'express'; +import { getResult } from './limbo.service'; + +interface LimboRequestBody { + clientSeed: string; +} + +export const placeBet = (req: Request, res: Response) => { + const { clientSeed } = req.body as LimboRequestBody; + + const result = getResult(clientSeed); + + res.status(200).json({ result }); +}; diff --git a/apps/api/src/features/games/limbo/limbo.router.ts b/apps/api/src/features/games/limbo/limbo.router.ts new file mode 100644 index 0000000..ddf789e --- /dev/null +++ b/apps/api/src/features/games/limbo/limbo.router.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { placeBet } from './limbo.controller'; + +const limboRouter: Router = Router(); + +limboRouter.post('/place-bet', placeBet); + +export default limboRouter; diff --git a/apps/api/src/features/games/limbo/limbo.service.ts b/apps/api/src/features/games/limbo/limbo.service.ts new file mode 100644 index 0000000..dbd77f8 --- /dev/null +++ b/apps/api/src/features/games/limbo/limbo.service.ts @@ -0,0 +1,16 @@ +const HOUSE_EDGE = 0.99; + +export const getResult = () => { + // const floats = rng.generateFloats({ + // clientSeed, + // count: 1, + // }); + const floats = [2]; + const floatPoint = (1e8 / (floats[0] * 1e8 + 1)) * HOUSE_EDGE; + + // Crash point rounded down to required denominator + const crashPoint = Math.floor(floatPoint * 100) / 100; + + // Consolidate all crash points below 1 + return Math.max(crashPoint, 1); +}; diff --git a/apps/api/src/features/games/mines/mines.constant.ts b/apps/api/src/features/games/mines/mines.constant.ts new file mode 100644 index 0000000..bb63e7b --- /dev/null +++ b/apps/api/src/features/games/mines/mines.constant.ts @@ -0,0 +1,331 @@ +// Check out the gist for the calculation: https://gist.github.com/nimit9/57309f2f9cc365ac090aef69e669bb6d +export const payouts: Record> = { + 1: { + 1: 1.03, + 2: 1.08, + 3: 1.12, + 4: 1.18, + 5: 1.24, + 6: 1.3, + 7: 1.37, + 8: 1.46, + 9: 1.55, + 10: 1.65, + 11: 1.77, + 12: 1.9, + 13: 2.06, + 14: 2.25, + 15: 2.48, + 16: 2.75, + 17: 3.09, + 18: 3.54, + 19: 4.13, + 20: 4.95, + 21: 6.19, + 22: 8.25, + 23: 12.38, + 24: 24.75, + }, + 2: { + 1: 1.08, + 2: 1.17, + 3: 1.29, + 4: 1.41, + 5: 1.56, + 6: 1.74, + 7: 1.94, + 8: 2.18, + 9: 2.48, + 10: 2.83, + 11: 3.26, + 12: 3.81, + 13: 4.5, + 14: 5.4, + 15: 6.6, + 16: 8.25, + 17: 10.61, + 18: 14.14, + 19: 19.8, + 20: 29.7, + 21: 49.5, + 22: 99, + 23: 297, + }, + 3: { + 1: 1.13, + 2: 1.29, + 3: 1.48, + 4: 1.71, + 5: 2, + 6: 2.35, + 7: 2.79, + 8: 3.35, + 9: 4.07, + 10: 5, + 11: 6.26, + 12: 7.96, + 13: 10.35, + 14: 13.8, + 15: 18.97, + 16: 27.11, + 17: 40.66, + 18: 65.06, + 19: 113.85, + 20: 227.7, + 21: 569.25, + 22: 2277, + }, + 4: { + 1: 1.18, + 2: 1.41, + 3: 1.71, + 4: 2.09, + 5: 2.58, + 6: 3.23, + 7: 4.09, + 8: 5.26, + 9: 6.88, + 10: 9.17, + 11: 12.51, + 12: 17.52, + 13: 25.3, + 14: 37.95, + 15: 59.64, + 16: 99.39, + 17: 178.91, + 18: 357.81, + 19: 834.9, + 20: 2504.7, + 21: 12523.5, + }, + 5: { + 1: 1.24, + 2: 1.56, + 3: 2, + 4: 2.58, + 5: 3.39, + 6: 4.52, + 7: 6.14, + 8: 8.5, + 9: 12.04, + 10: 17.52, + 11: 26.27, + 12: 40.87, + 13: 66.41, + 14: 113.85, + 15: 208.73, + 16: 417.45, + 17: 939.26, + 18: 2504.7, + 19: 8766.45, + 20: 52598.7, + }, + 6: { + 1: 1.3, + 2: 1.74, + 3: 2.35, + 4: 3.23, + 5: 4.52, + 6: 6.46, + 7: 9.44, + 8: 14.17, + 9: 21.89, + 10: 35.03, + 11: 58.38, + 12: 102.17, + 13: 189.75, + 14: 379.5, + 15: 834.9, + 16: 2087.25, + 17: 6261.75, + 18: 25047, + 19: 175329, + }, + 7: { + 1: 1.38, + 2: 1.94, + 3: 2.79, + 4: 4.09, + 5: 6.14, + 6: 9.44, + 7: 14.95, + 8: 24.47, + 9: 41.6, + 10: 73.95, + 11: 138.66, + 12: 277.33, + 13: 600.88, + 14: 1442.1, + 15: 3965.78, + 16: 13219.25, + 17: 59486.63, + 18: 475893, + }, + 8: { + 1: 1.46, + 2: 2.18, + 3: 3.35, + 4: 5.26, + 5: 8.5, + 6: 14.17, + 7: 24.47, + 8: 44.05, + 9: 83.2, + 10: 166.4, + 11: 356.56, + 12: 831.98, + 13: 2163.15, + 14: 6489.45, + 15: 23794.65, + 16: 118973.25, + 17: 1070759.25, + }, + 9: { + 1: 1.55, + 2: 2.47, + 3: 4.07, + 4: 6.88, + 5: 12.04, + 6: 21.89, + 7: 41.6, + 8: 83.2, + 9: 176.8, + 10: 404.1, + 11: 1010.26, + 12: 2828.73, + 13: 9193.39, + 14: 36773.55, + 15: 202254.52, + 16: 2022545.25, + }, + 10: { + 1: 1.65, + 2: 2.83, + 3: 5, + 4: 9.17, + 5: 17.52, + 6: 35.03, + 7: 73.95, + 8: 166.4, + 9: 404.1, + 10: 1077.61, + 11: 3232.84, + 12: 11314.94, + 13: 49031.4, + 14: 294188.4, + 15: 3236072.4, + }, + 11: { + 1: 1.77, + 2: 3.26, + 3: 6.26, + 4: 12.51, + 5: 26.27, + 6: 58.38, + 7: 138.66, + 8: 356.56, + 9: 1010.26, + 10: 3232.84, + 11: 12123.15, + 12: 56574.69, + 13: 367735.5, + 14: 4412826, + }, + 12: { + 1: 1.9, + 2: 3.81, + 3: 7.96, + 4: 17.52, + 5: 40.87, + 6: 102.17, + 7: 277.33, + 8: 831.98, + 9: 2828.73, + 10: 11314.94, + 11: 56574.69, + 12: 396022.85, + 13: 5148297, + }, + 13: { + 1: 2.06, + 2: 4.5, + 3: 10.35, + 4: 25.3, + 5: 66.41, + 6: 189.75, + 7: 600.88, + 8: 2163.15, + 9: 9193.39, + 10: 49031.4, + 11: 367735.5, + 12: 5148297, + }, + 14: { + 1: 2.25, + 2: 5.4, + 3: 13.8, + 4: 37.95, + 5: 113.85, + 6: 379.5, + 7: 1442.1, + 8: 6489.45, + 9: 36773.55, + 10: 294188.4, + 11: 4412826, + }, + 15: { + 1: 2.47, + 2: 6.6, + 3: 18.97, + 4: 59.64, + 5: 208.73, + 6: 834.9, + 7: 3965.78, + 8: 23794.65, + 9: 202254.53, + 10: 3236072.4, + }, + 16: { + 1: 2.75, + 2: 8.25, + 3: 27.11, + 4: 99.39, + 5: 417.45, + 6: 2087.25, + 7: 13219.25, + 8: 118973.25, + 9: 2022545.25, + }, + 17: { + 1: 3.09, + 2: 10.61, + 3: 40.66, + 4: 178.91, + 5: 939.26, + 6: 6261.75, + 7: 59486.63, + 8: 1070759.25, + }, + 18: { + 1: 3.54, + 2: 14.14, + 3: 65.06, + 4: 357.81, + 5: 2504.7, + 6: 25047, + 7: 475893, + }, + 19: { + 1: 4.13, + 2: 19.8, + 3: 113.85, + 4: 834.9, + 5: 8766.45, + 6: 175329, + }, + 20: { 1: 4.95, 2: 29.7, 3: 227.7, 4: 2504.7, 5: 52598.7 }, + 21: { 1: 6.19, 2: 49.5, 3: 569.25, 4: 12523.5 }, + 22: { 1: 8.25, 2: 99, 3: 2277 }, + 23: { 1: 12.38, 2: 297 }, + 24: { 1: 24.75 }, +}; diff --git a/apps/api/src/features/games/mines/mines.controller.ts b/apps/api/src/features/games/mines/mines.controller.ts new file mode 100644 index 0000000..a6601b2 --- /dev/null +++ b/apps/api/src/features/games/mines/mines.controller.ts @@ -0,0 +1,115 @@ +import type { Request, Response } from 'express'; +import { MinesBetSchema } from '@repo/common/game-utils/mines/validations.js'; +import type { User } from '@prisma/client'; +import { ApiResponse } from '@repo/common/types'; +import { StatusCodes } from 'http-status-codes'; +import type { + MinesGameOverResponse, + MinesHiddenState, + MinesPlayRoundResponse, +} from '@repo/common/game-utils/mines/types.js'; +import { minesManager } from './mines.service'; +import { BadRequestError, NotFoundError } from '../../../errors'; +import { minorToAmount } from '../../../utils/bet.utils'; + +export const startGame = async ( + req: Request, + res: Response | { message: string }> +) => { + const validationResult = MinesBetSchema.safeParse(req.body); + + const { betAmountInCents, userInstance } = req.validatedBet!; + + if (!validationResult.success) { + throw new BadRequestError(validationResult.error.message); + } + + const { minesCount } = validationResult.data; + + const transaction = await minesManager.createGame({ + betAmount: betAmountInCents, + minesCount, + userInstance, + }); + + const { bet, newBalance } = transaction; + + res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + id: bet.id, + active: true, + state: { mines: null, minesCount, rounds: [] }, + betAmount: minorToAmount(betAmountInCents), + balance: minorToAmount(parseFloat(newBalance)), + }) + ); +}; + +export const playRound = async ( + req: Request, + res: Response< + | ApiResponse + | { message: string } + > +) => { + const { selectedTileIndex, game, userInstance } = req.validatedRequest!; + const gameState = await game.playRound(selectedTileIndex); + if (!gameState.active) { + minesManager.deleteGame(userInstance.getUserId()); + } + return res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, gameState)); +}; + +export const cashOut = async ( + req: Request, + res: Response< + ApiResponse | { message: string } + > +) => { + const userId = (req.user as User).id; + const game = await minesManager.getGame(userId); + if (!game?.getBet().active) { + throw new NotFoundError('Game not found'); + } + + const gameState = await game.cashOut(userId); + minesManager.deleteGame(userId); + return res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, gameState)); +}; + +export const getActiveGame = async ( + req: Request, + res: Response< + ApiResponse | { message: string } + > +) => { + const userId = (req.user as User).id; + const game = await minesManager.getGame(userId); + + if (!game) { + throw new NotFoundError('Game not found'); + } + + const activeBet = game.getBet(); + + if (!activeBet.active || !activeBet.state) { + throw new NotFoundError('Game not found'); + } + + return res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + id: activeBet.id, + active: activeBet.active, + state: { + mines: null, + rounds: game.getRounds(), + minesCount: (activeBet.state as unknown as MinesHiddenState).minesCount, + }, + betAmount: activeBet.betAmount / 100, + }) + ); +}; diff --git a/apps/api/src/features/games/mines/mines.middleware.ts b/apps/api/src/features/games/mines/mines.middleware.ts new file mode 100644 index 0000000..43a440c --- /dev/null +++ b/apps/api/src/features/games/mines/mines.middleware.ts @@ -0,0 +1,48 @@ +import type { Request, Response, NextFunction } from 'express'; +import { UserInstance, userManager } from '../../user/user.service'; +import { User } from '@prisma/client'; +import { Mines, minesManager } from './mines.service'; +import { BadRequestError } from '../../../errors'; + +interface PlayRoundRequestBody { + selectedTileIndex: number; + id: string; +} + +declare module 'express' { + interface Request { + validatedRequest?: { + selectedTileIndex: number; + game: Mines; + userInstance: UserInstance; + }; + } +} + +export const validatePlayRoundRequest = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const userId = (req.user as User).id; + const userInstance = await userManager.getUser(userId); + const user = userInstance.getUser(); + + const game = await minesManager.getGame(user.id); + + if (!game?.getBet().active) { + throw new BadRequestError('Game not found'); + } + + const { selectedTileIndex } = req.body as PlayRoundRequestBody; + + game.validatePlayRound(selectedTileIndex); + + req.validatedRequest = { + selectedTileIndex, + game, + userInstance, + }; + + next(); +}; diff --git a/apps/api/src/features/games/mines/mines.router.ts b/apps/api/src/features/games/mines/mines.router.ts new file mode 100644 index 0000000..61db257 --- /dev/null +++ b/apps/api/src/features/games/mines/mines.router.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../../../middlewares/auth.middleware'; +import { + cashOut, + getActiveGame, + playRound, + startGame, +} from './mines.controller'; +import { validateBet } from '../../../middlewares/bet.middleware'; +import { validatePlayRoundRequest } from './mines.middleware'; +import { rateLimitBets } from '../../../middlewares/rateLimit.middleware'; + +const minesRouter: Router = Router(); + +minesRouter.post( + '/start', + isAuthenticated, + rateLimitBets({ maxBetsPerMinute: 30 }), + validateBet, + startGame +); +minesRouter.post( + '/play-round', + isAuthenticated, + validatePlayRoundRequest, + playRound +); +minesRouter.post('/cash-out', isAuthenticated, cashOut); +minesRouter.get('/active', isAuthenticated, getActiveGame); + +export default minesRouter; diff --git a/apps/api/src/features/games/mines/mines.service.ts b/apps/api/src/features/games/mines/mines.service.ts new file mode 100644 index 0000000..b20bd14 --- /dev/null +++ b/apps/api/src/features/games/mines/mines.service.ts @@ -0,0 +1,200 @@ +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import type { + MinesBet, + MinesGameOverResponse, + MinesHiddenState, + MinesPlayRoundResponse, + MinesRevealedState, +} from '@repo/common/game-utils/mines/types.js'; +import db from '@repo/db'; +import type { Bet } from '@prisma/client'; +import { + convertFloatsToGameEvents, + calculateMines, +} from '@repo/common/game-utils/mines/utils.js'; +import { userManager, UserInstance } from '../../user/user.service'; +import { payouts } from './mines.constant'; +import { + createBetTransaction, + editBetAndUpdateBalance, + minorToAmount, +} from '../../../utils/bet.utils'; +import { BadRequestError } from '../../../errors'; + +class MinesManager { + private static instance: MinesManager | undefined; + private games: Map; + + private constructor() { + this.games = new Map(); + } + + static getInstance() { + if (!MinesManager.instance) { + MinesManager.instance = new MinesManager(); + } + return MinesManager.instance; + } + + async getGame(userId: string) { + if (!this.games.has(userId)) { + const bet = await db.bet.findFirst({ + where: { userId, active: true, game: 'mines' }, + }); + if (!bet) { + return null; + } + const game = new Mines(bet); + this.games.set(userId, game); + } + return this.games.get(userId); + } + + async createGame({ + minesCount, + betAmount, + userInstance, + }: MinesBet & { userInstance: UserInstance }) { + const floats = userInstance.generateFloats(NO_OF_TILES - 1); + const gameEvents = convertFloatsToGameEvents(floats, NO_OF_TILES); + const mines = calculateMines(gameEvents, minesCount); + const transaction = await createBetTransaction({ + active: true, + betAmount, + game: 'mines', + gameState: { mines, minesCount, rounds: [] }, + userInstance, + }); + const game = new Mines(transaction.bet); + this.games.set(userInstance.getUserId(), game); + return transaction; + } + + deleteGame(userId: string) { + this.games.delete(userId); + } +} + +export class Mines { + private bet: Bet; + private rounds: { selectedTileIndex: number; payoutMultiplier: number }[] = + []; + private selectedTiles: number[] = []; + constructor(bet: Bet) { + this.bet = bet; + } + + getBet() { + return this.bet; + } + + getRounds() { + return this.rounds; + } + + validatePlayRound(selectedTileIndex: number) { + if (this.selectedTiles.includes(selectedTileIndex)) { + throw new BadRequestError('Tile already selected'); + } + if (this.rounds.length === NO_OF_TILES - 1) { + throw new BadRequestError('Game over'); + } + if (!this.bet.state) { + throw new BadRequestError('Game state not found'); + } + + const { mines } = this.bet.state as unknown as + | MinesHiddenState + | MinesRevealedState; + + if (!mines) { + throw new BadRequestError('Game not started'); + } + } + + async updateDbAndGetGameState( + minesCount: number + ): Promise { + await db.bet.update({ + where: { id: this.bet.id, active: true }, + data: { + state: { + ...(this.bet.state as unknown as MinesHiddenState), + rounds: this.rounds, + }, + }, + }); + return { + id: this.bet.id, + active: true, + state: { + rounds: this.rounds, + mines: null, + minesCount, + }, + betAmount: this.bet.betAmount / 100, + }; + } + + async playRound( + selectedTileIndex: number + ): Promise { + this.selectedTiles.push(selectedTileIndex); + const { mines, minesCount } = this.bet + .state as unknown as MinesRevealedState; + + if (mines.includes(selectedTileIndex)) { + this.rounds.push({ selectedTileIndex, payoutMultiplier: 0 }); + return this.getGameOverState(this.bet.userId); + } + + const gemsCount = this.rounds.length + 1; + const payoutMultiplier = payouts[gemsCount][minesCount]; + this.rounds.push({ selectedTileIndex, payoutMultiplier }); + + return await this.updateDbAndGetGameState(minesCount); + } + + async cashOut(userId: string) { + if (this.rounds.length === 0) { + throw new Error('Game not started'); + } + return this.getGameOverState(userId); + } + + private async getGameOverState( + userId: string + ): Promise { + const userInstance = await userManager.getUser(userId); + const payoutMultiplier = this.rounds.at(-1)?.payoutMultiplier || 0; + const payoutAmount = payoutMultiplier * this.bet.betAmount; + + const { newBalance } = await editBetAndUpdateBalance({ + betId: this.bet.id, + userInstance, + payoutAmount, + betAmount: 0, + data: { + payoutAmount, + active: false, + state: { + ...(this.bet.state as unknown as MinesRevealedState), + rounds: this.rounds, + }, + }, + }); + return { + id: this.bet.id, + state: { + ...(this.bet.state as unknown as MinesRevealedState), + rounds: this.rounds, + }, + payoutMultiplier, + payout: minorToAmount(payoutAmount), + balance: minorToAmount(parseFloat(newBalance)), + active: false, + }; + } +} + +export const minesManager = MinesManager.getInstance(); diff --git a/apps/api/src/features/games/plinkoo/plinkoo.constants.ts b/apps/api/src/features/games/plinkoo/plinkoo.constants.ts new file mode 100644 index 0000000..8e8e1b4 --- /dev/null +++ b/apps/api/src/features/games/plinkoo/plinkoo.constants.ts @@ -0,0 +1,189 @@ +export const TOTAL_DROPS = 16; + +export const MULTIPLIERS: Record = { + 0: 16, + 1: 9, + 2: 2, + 3: 1.4, + 4: 1.4, + 5: 1.2, + 6: 1.1, + 7: 1, + 8: 0.5, + 9: 1, + 10: 1.1, + 11: 1.2, + 12: 1.4, + 13: 1.4, + 14: 2, + 15: 9, + 16: 16, +}; + +export const OUTCOMES: Record = { + '0': [], + '1': [3964963.452981615, 3910113.3998412564], + '2': [ + 3980805.7004139693, 3945617.6504109767, 4027628.395823398, + 3902115.8620758583, 3938709.5467746584, + ], + '3': [ + 3975554.824601942, 3965805.769610554, 3909279.443666201, 3940971.550465178, + 3909606.717374134, 3915484.1741136736, 3977018.430328505, + 3979167.5933461944, 3995981.0273005674, 3974177.78840204, + ], + '4': [ + 3943174.7607756723, 3992961.0886867167, 3914511.2798374896, + 3950487.300703086, 3973378.3900412438, 4012888.985549594, + 4040961.8767680754, 4066503.3857407006, 3944573.7194061875, + 3979876.769324002, 4042712.772834604, 4032991.0303322095, + 4046340.7919081766, 3912597.9665436875, 4068852.495940549, + 4064879.257329362, 3996796.04239161, 4045062.2783860737, 3964680.919169739, + ], + '5': [ + 3953045.1447091424, 3947374.62976226, 3924082.6101653073, 3919085.269354398, + 3902650.4008744615, 3934968.1593932374, 4044126.7590222214, + 3928499.8807134246, 3913801.9247018984, 3909595.4432100505, + 4082827.827013994, 3979739.108665962, 4077651.317785833, 4008030.8883127486, + 3950951.6007580766, 3992039.9053288833, 4021810.0928285993, + 4052650.560434505, 3994806.267259329, 3959327.3735489477, + 3940455.7641962855, 3998822.2807239015, 3998803.9335444313, + 4068193.3913483596, 3938798.911585438, + ], + '6': [ + 4065643.7049927213, 3936841.961313155, 3948472.8991447487, + 4004510.5975928125, 3933695.6888747592, 4011296.1958215656, + 4093232.84383817, 3945658.6170622837, 4063199.5117669366, 4037864.799653558, + 3931477.3517858014, 4091381.513010509, 4000895.053297006, + 4042867.6535872207, 4090947.938511616, 3989468.333758437, 3943335.764879169, + 3947278.536321405, 4022304.817103859, 3902177.8466275427, 3925270.959381573, + 3955253.4540312397, 3986641.0060988157, 3927696.2396482667, + 4064571.150949869, 3991167.946685552, 3973041.308793569, 3987377.180906899, + 3917262.667253392, 4002606.795366179, 4033596.992526079, 3901372.366183016, + 4015207.583244224, 3955421.290959922, 3952223.0425123484, + 3941774.4498685915, 3977289.3718391117, 4024943.3014183883, + 4024885.5052148327, 4016596.7449097126, 3910164.1864616796, + 4023400.498352244, 3981421.8628830933, 3913377.3496230906, + 4045958.9425667236, 4071139.892029292, 4019862.922309672, + 4027992.2300945413, 4030455.1701347437, 4060673.10227606, 3996564.062673036, + 4009801.4052053, 4007734.404953163, 4046612.754675019, 3944956.9979153597, + 3977382.889196781, 3906636.5132748624, 4080470.0674178666, + 3996210.4877184015, 3956216.294023866, 3940040.183231992, + ], + '7': [ + 3926739.9104774813, 4091374.44234272, 4061919.9903071183, + 3976066.7555194413, 3948801.1936986246, 4043233.7830772344, + 4010011.7658794387, 3936431.4108806592, 3942776.8649452417, + 3909995.011479453, 4012272.43979473, 3989907.069429411, 3996182.4336681785, + 4078644.79693604, 4081624.0834239917, 4025044.731614778, 4033602.5381773794, + 3913189.826642105, 3910500.674962151, 4055296.6588616692, + 4005574.8641647273, 4079800.3518520766, 4092763.5236495608, + 3952185.4910905147, 3945510.495018459, 3920891.8818843197, + 3997101.789672143, 3991974.822516503, 3949265.4371072412, + 3933412.4749754136, 3933181.8312838264, 4063875.6616431624, + 3998206.7252218956, 3959006.1987530286, 3924067.917601976, + 3902914.4459602935, 3905347.098696195, 4000831.565288375, 3944915.3251241, + 3930343.481158048, 4025858.616981573, 4026496.026592473, 3948116.019901921, + 4067143.737297127, 3995156.000931595, 3905006.3301882823, + 4035783.4852589793, 3956461.6106608217, 4032886.6912715673, + 3913146.10237042, 3930772.085213345, 3984887.619042549, 4053031.0321973227, + 3913395.137097174, 3993579.678508536, 3932427.236196532, 3984279.0886106077, + ], + '8': [ + 4099062.75134143, 4085894.4181278455, 3991123.0115790954, + 3973053.5827605873, 3968190.564301313, 3925604.5066868863, + 3933898.7590061547, 4089919.7991958153, 4076997.5225973814, + 3957630.60529322, 3948999.35996541, 3963938.9455971997, 4044805.7991237757, + 3905133.2109927135, 4074463.6876271376, 3939301.0655442886, + 4040571.320635691, 4020510.19979044, 3959835.4618981928, 4037241.67248416, + 4043105.87901907, 3912654.2409310103, 3929773.262095125, 3950802.527033251, + 4068582.4605300324, 3946792.6177569656, 4078475.9982660934, + 3972024.763383927, 3947150.677862883, 3963410.9779685168, 3999134.851845996, + 3909374.1117644133, 3942761.896008833, 4071253.4107468165, 4050534.50171971, + 3988521.4618817912, 3929940.089627246, 4029305.1056314665, + 4087943.221841722, 3910909.3079385986, 4046944.0552393594, + 4006944.159180551, 4014707.657017377, 3925473.574267122, 4012158.905329344, + 4042197.149473071, 3998434.6078570196, 4047267.2747256896, + 3964753.3725316986, 3955821.0222197613, 3973475.662585886, + 3917189.0280630635, 4027132.7848505056, 3905368.7668914935, + 3936654.62186107, 4092566.3229272505, 4026541.0685970024, + 4038770.6420815475, 4067262.4257867294, 4050430.5327158393, + 3980149.8069138955, 4052184.5678737606, 3942299.598280835, + 4079754.687607573, 4021112.5651541506, 3961023.3381184433, + 3937025.1424917267, 3964607.486702018, 4001319.0133674755, + 3941648.5232227165, 4030587.9685114417, 4044067.1579758436, + 4058158.522928313, + ], + '9': [ + 3911530.315770063, 4024711.492410591, 3967652.4297853387, + 4098886.3793751886, 4026117.0283389515, 4045045.4095477182, + 4034571.220507859, 4088809.303306565, 3900806.968890352, 3913166.9251142726, + 4059594.3600833854, 3945137.694311404, 3902668.8160601873, + 4054646.2889849013, 4053898.6542759663, 3959251.11275926, 3963475.882565954, + 3967968.9310842347, 4075078.929914972, 4035117.4533019722, + 4047608.2592268144, 3913024.5010530455, 4081362.0390194473, + 4098538.7144543654, 4049336.7774994993, 4056844.5727342237, + 3917845.6810319433, 4098332.1779752634, 3979547.7686487637, + 4026747.155594485, 3944692.803167993, 3960649.105237204, 4081040.2295870385, + 4005698.9658651184, 4074183.694152899, 3976184.3586868607, + 4007157.5084493076, 3918927.3398626954, 3918166.0285542854, + 3953868.3374998523, 3963648.6249533077, 4065036.1837552087, + 3964230.698479104, 3992799.530672317, 3931113.922813188, 4082916.6661583954, + 3919236.111874976, 4012743.1541231154, 3900406.2441578982, + 4031396.764516756, 4088712.2834741194, 3921570.4946371615, 4077416.64169384, + 3962807.6000533635, + ], + '10': [ + 4069582.648305392, 3966300.3577461895, 4047184.7847023425, + 3962656.256238744, 3934682.0223851865, 4089620.291559703, 3996605.065672608, + 3921656.567101851, 3950930.30704122, 4052733.606190915, 4046762.051641918, + 3912718.72211605, 3942094.6698735086, 4017504.735499972, 4016206.1612997893, + 4060896.040328729, 4077224.686824909, 3988932.185505723, 4016550.502499315, + 3959104.134236025, 3903531.023685199, 3939907.5585800377, 3969464.753065079, + 4036549.7059165714, 3938844.715578784, 3985594.4268763512, + 4011615.276676018, 3949739.058361909, 4064041.8926257566, 4004767.498301687, + 3996411.8026064364, 4035064.3182208547, 3988008.7378418343, + 4015638.96642283, 3967068.722994021, 4082965.2856357233, 3951302.134707721, + 3948101.1830631103, 3978745.8509503608, 4068638.265329366, + 4018433.726155858, 4032765.523475676, + ], + '11': [ + 4055462.593704495, 4027576.362231998, 4011290.7395424685, + 4034848.6574270525, 4064298.598636101, 3997022.919190929, 4053625.932623065, + 4064234.3514714935, 4075348.9710445153, 4060118.5348266517, + 4065992.932112665, 4063162.143518177, 4060798.1858924176, 3956764.654354398, + 3912916.1668887464, 4018282.0763658765, 4065575.3280486814, + 3967348.3916016137, 4034992.477051428, 4069123.2018048204, + 3939281.4172981237, 4022103.802712647, 4083993.320300048, 4034478.871034405, + 4068844.513451607, 4097187.535489012, 3981130.4047553614, + 4068312.6406908804, 4050921.0879167155, 4048297.277514315, + 3953878.475004285, 3998627.3710734197, + ], + '12': [ + 4007152.5182738686, 4014664.8542149696, 4095619.5802802853, + 4018084.7270321106, 4072050.3744347296, 4026256.723716898, + 4095827.9573665825, 4023631.9896559394, 4046751.9125588783, + 3973758.674124694, 4081927.075527175, 3922485.387310559, 4001549.2805312183, + 4050417.849670596, 3987607.4531957353, 4060206.9664999805, + 4080316.8473846694, 4030455.1532406537, 4087714.965906726, + 4028165.0792610054, 4032588.5261474997, 3980546.468460318, + 4090408.033691761, 3990019.103297975, 4088755.998466496, 4092162.22327816, + 4029036.6583707742, 4055066.505591603, 4081998.821392285, 4079550.553314541, + ], + '13': [ + 3905319.849889843, 4054719.0660902266, 4055596.4319745116, + 3992648.989962779, 3924972.5941170114, 4095167.7814041013, + 3912740.1944122575, 4024882.9438952096, 4023171.3988155797, + 4059892.954049364, 4068510.96886605, 4093838.431690223, 4070524.1327491063, + ], + '14': [ + 4092261.8249403643, 3956304.3865069468, 4069053.2302732924, + 4038890.8473817194, + ], + '15': [ + 4013891.110502415, 3977489.9532032954, 4044335.989753631, + 4066199.8081775964, + ], + '16': [3979706.1687804307, 4024156.037977316], + '17': [], +}; diff --git a/apps/api/src/features/games/plinkoo/plinkoo.controller.ts b/apps/api/src/features/games/plinkoo/plinkoo.controller.ts new file mode 100644 index 0000000..859df81 --- /dev/null +++ b/apps/api/src/features/games/plinkoo/plinkoo.controller.ts @@ -0,0 +1,10 @@ +import type { Request, Response } from 'express'; +import { calculateOutcome } from './plinkoo.service'; + +export const getOutcome = (req: Request, res: Response): void => { + const { clientSeed = 'P7xjSv-1ff' } = req.body as { + clientSeed: string; + }; + const result = calculateOutcome(clientSeed); + res.send(result); +}; diff --git a/apps/api/src/features/games/plinkoo/plinkoo.router.ts b/apps/api/src/features/games/plinkoo/plinkoo.router.ts new file mode 100644 index 0000000..9a18da8 --- /dev/null +++ b/apps/api/src/features/games/plinkoo/plinkoo.router.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { getOutcome } from './plinkoo.controller'; + +const plinkooRouter: Router = Router(); + +plinkooRouter.post('/outcome', getOutcome); + +export default plinkooRouter; diff --git a/apps/api/src/features/games/plinkoo/plinkoo.service.ts b/apps/api/src/features/games/plinkoo/plinkoo.service.ts new file mode 100644 index 0000000..42ca2af --- /dev/null +++ b/apps/api/src/features/games/plinkoo/plinkoo.service.ts @@ -0,0 +1,32 @@ +// import { rng } from '../../user/user.service'; +import { OUTCOMES, MULTIPLIERS } from './plinkoo.constants'; + +type TPattern = ('L' | 'R')[]; + +const DIRECTIONS: TPattern = ['L', 'R']; + +export const calculateOutcome = () => { + let outcome = 0; + const pattern: TPattern = []; + // const floats = rng.generateFloats({ clientSeed, count: TOTAL_DROPS }); + const floats = [2]; + floats.forEach(float => { + const direction = DIRECTIONS[Math.floor(float * 2)]; // 0 or 1 -> L or R + pattern.push(direction); + if (direction === 'R') { + outcome++; + } + }); + + const multiplier = MULTIPLIERS[outcome]; + const possiblieOutcomes = OUTCOMES[outcome]; + + return { + point: + possiblieOutcomes[ + Math.floor(Math.random() * possiblieOutcomes.length || 0) + ], + multiplier, + pattern, + }; +}; diff --git a/apps/api/src/features/games/roulette/roulette.constants.ts b/apps/api/src/features/games/roulette/roulette.constants.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/features/games/roulette/roulette.controller.ts b/apps/api/src/features/games/roulette/roulette.controller.ts new file mode 100644 index 0000000..3f30a94 --- /dev/null +++ b/apps/api/src/features/games/roulette/roulette.controller.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from 'express'; +import type { + RouletteBet, + RoulettePlaceBetResponse, +} from '@repo/common/game-utils/roulette/index.js'; +import { StatusCodes } from 'http-status-codes'; +import { ApiResponse } from '@repo/common/types'; +import { calculatePayout, spinWheel } from './roulette.service'; +import { + createBetTransaction, + minorToAmount, + amountToMinor, +} from '../../../utils/bet.utils'; +import { formatGameResponse } from '../../../utils/game.utils'; + +export const placeBetAndSpin = async ( + request: Request, + res: Response> +): Promise => { + const { bets } = request.body as { bets: RouletteBet[] }; + + const { userInstance, betAmountInCents } = request.validatedBet!; + + const winningNumber = await spinWheel(userInstance); + + const payout = calculatePayout(bets, winningNumber); + + const gameState = { + bets, + winningNumber: String(winningNumber), + }; + + const payoutInCents = Math.round(payout * 100); + + const transaction = await createBetTransaction({ + betAmount: betAmountInCents, + userInstance, + game: 'roulette', + gameState, + payoutAmount: payoutInCents, // Convert to dollars + active: false, + }); + + const response = formatGameResponse( + { + gameState, + payout: minorToAmount(payoutInCents), + payoutMultiplier: payoutInCents / betAmountInCents, + }, + transaction, + amountToMinor(betAmountInCents) + ); + + res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + id: transaction.bet.id, + state: gameState, + payoutMultiplier: response.payoutMultiplier, + payout: response.payout, + balance: response.balance, + }) + ); +}; diff --git a/apps/api/src/features/games/roulette/roulette.middleware.ts b/apps/api/src/features/games/roulette/roulette.middleware.ts new file mode 100644 index 0000000..6ae493a --- /dev/null +++ b/apps/api/src/features/games/roulette/roulette.middleware.ts @@ -0,0 +1,31 @@ +import { + BetsSchema, + validateBets, +} from '@repo/common/game-utils/roulette/validations.js'; +import { NextFunction, Request, Response } from 'express'; +import { BadRequestError } from '../../../errors'; +import sum from 'lodash/sum'; + +export const validateRouletteBet = ( + req: Request, + res: Response, + next: NextFunction +) => { + const validationResult = BetsSchema.safeParse(req.body); + + if (!validationResult.success) { + throw new BadRequestError('Invalid request for bets'); + } + + const { bets } = validationResult.data; + + const validBets = validateBets(bets); + + if (validBets.length === 0) { + throw new BadRequestError('No valid bets placed'); + } + + req.body.betAmount = Math.round(sum(bets.map(bet => bet.amount))); + + next(); +}; diff --git a/apps/api/src/features/games/roulette/roulette.router.ts b/apps/api/src/features/games/roulette/roulette.router.ts new file mode 100644 index 0000000..1cc3873 --- /dev/null +++ b/apps/api/src/features/games/roulette/roulette.router.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../../../middlewares/auth.middleware'; +import { placeBetAndSpin } from './roulette.controller'; +import { validateBet } from '../../../middlewares/bet.middleware'; +import { validateRouletteBet } from './roulette.middleware'; +import { rateLimitBets } from '../../../middlewares/rateLimit.middleware'; + +const rouletteRouter: Router = Router(); + +rouletteRouter.post( + '/place-bet', + isAuthenticated, + rateLimitBets({ maxBetsPerMinute: 30 }), + validateRouletteBet, + validateBet, + placeBetAndSpin +); + +export default rouletteRouter; diff --git a/apps/api/src/features/games/roulette/roulette.service.ts b/apps/api/src/features/games/roulette/roulette.service.ts new file mode 100644 index 0000000..fa8f4e1 --- /dev/null +++ b/apps/api/src/features/games/roulette/roulette.service.ts @@ -0,0 +1,81 @@ +import { + blackNumbers, + redNumbers, + type RouletteBet, + RouletteBetTypes, +} from '@repo/common/game-utils/roulette/index.js'; +import { sum } from 'lodash'; +import { userManager, UserInstance } from '../../user/user.service'; +import { isNumberInRange } from '../../../utils/numbers'; + +const spinWheel = async (userInstance: UserInstance) => { + const [float] = userInstance.generateFloats(1); + return Math.floor(float * 37); // Generates a number between 0 and 36 +}; + +const calculatePayout = (bets: RouletteBet[], winningNumber: number) => { + const payouts = bets.map(bet => { + switch (bet.betType) { + case RouletteBetTypes.STRAIGHT: + return bet.selection === winningNumber ? bet.amount * 36 : 0; + case RouletteBetTypes.SPLIT: + return bet.selection.includes(winningNumber) ? bet.amount * 18 : 0; + case RouletteBetTypes.STREET: + return bet.selection.includes(winningNumber) ? bet.amount * 12 : 0; + case RouletteBetTypes.CORNER: + return bet.selection.includes(winningNumber) ? bet.amount * 9 : 0; + case RouletteBetTypes.SIXLINE: + return bet.selection.includes(winningNumber) ? bet.amount * 6 : 0; + case RouletteBetTypes.DOZEN: + switch (bet.selection) { + case 1: + return isNumberInRange(winningNumber, 1, 12) ? bet.amount * 3 : 0; + case 2: + return isNumberInRange(winningNumber, 13, 24) ? bet.amount * 3 : 0; + case 3: + return isNumberInRange(winningNumber, 25, 36) ? bet.amount * 3 : 0; + default: + return 0; + } + case RouletteBetTypes.COLUMN: { + if (winningNumber === 0) return 0; + switch (bet.selection) { + case 1: + return winningNumber % 3 === 1 ? bet.amount * 3 : 0; + case 2: + return winningNumber % 3 === 2 ? bet.amount * 3 : 0; + case 3: + return winningNumber % 3 === 0 ? bet.amount * 3 : 0; + default: + return 0; + } + } + case RouletteBetTypes.BLACK: + return winningNumber !== 0 && + blackNumbers.includes(winningNumber.toString()) + ? bet.amount * 2 + : 0; + case RouletteBetTypes.RED: + return winningNumber !== 0 && + redNumbers.includes(winningNumber.toString()) + ? bet.amount * 2 + : 0; + case RouletteBetTypes.EVEN: + return winningNumber !== 0 && winningNumber % 2 === 0 + ? bet.amount * 2 + : 0; + case RouletteBetTypes.ODD: + return winningNumber % 2 === 1 ? bet.amount * 2 : 0; + case RouletteBetTypes.HIGH: + return isNumberInRange(winningNumber, 19, 36) ? bet.amount * 2 : 0; + case RouletteBetTypes.LOW: + return isNumberInRange(winningNumber, 1, 18) ? bet.amount * 2 : 0; + default: + return 0; + } + }); + + return sum(payouts); +}; + +export { spinWheel, calculatePayout }; diff --git a/apps/api/src/features/user/user.controller.ts b/apps/api/src/features/user/user.controller.ts new file mode 100644 index 0000000..6c91894 --- /dev/null +++ b/apps/api/src/features/user/user.controller.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import type { User } from '@prisma/client'; +import { ApiResponse } from '@repo/common/types'; +import type { + PaginatedBetsResponse, + ProvablyFairStateResponse, +} from '@repo/common/types'; +import db from '@repo/db'; +import { BadRequestError } from '../../errors'; +import { userManager, getUserBets } from './user.service'; +import { use } from 'passport'; + +export const getBalance = async (req: Request, res: Response) => { + const userInstance = await userManager.getUser((req.user as User).id); + const balanceInCents = userInstance.getBalanceAsNumber(); + const balance = balanceInCents / 100; // Convert from cents to dollars + return res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, { balance })); +}; + +export const rotateSeed = async ( + req: Request, + res: Response> +) => { + const { clientSeed } = req.body as { clientSeed: string }; + if (!clientSeed) { + throw new BadRequestError('Client seed is required'); + } + + const userInstance = await userManager.getUser((req.user as User).id); + + const activeBet = await db.bet.findFirst({ + where: { userId: (req.user as User).id, active: true }, + }); + + if (activeBet) { + throw new BadRequestError('Cannot rotate seeds while a bet is active'); + } + + const seed = await userInstance.rotateSeed(clientSeed); + return res.status(StatusCodes.OK).json(new ApiResponse(StatusCodes.OK, seed)); +}; + +export const getProvablyFairState = async ( + req: Request, + res: Response> +) => { + const userInstance = await userManager.getUser((req.user as User).id); + const activeBets = await userManager.getActiveBets(userInstance.getUserId()); + + const activeGames = new Set(activeBets.map(bet => bet.game)); + const canRotate = activeGames.size === 0; + + return res.status(StatusCodes.OK).json( + new ApiResponse(StatusCodes.OK, { + clientSeed: userInstance.getClientSeed(), + hashedServerSeed: userInstance.getHashedServerSeed(), + hashedNextServerSeed: userInstance.getHashedNextServerSeed(), + nonce: userInstance.getNonce(), + canRotate, + activeGames: Array.from(activeGames), + }) + ); +}; + +export const getRevealedServerSeed = async ( + req: Request, + res: Response> +) => { + const { hashedServerSeed } = req.params; + + if (!hashedServerSeed) { + throw new BadRequestError('Hashed server seed is required'); + } + + const userInstance = await userManager.getUser((req.user as User).id); + const serverSeed = + await userInstance.getRevealedServerSeedByHash(hashedServerSeed); + + return res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, { serverSeed })); +}; + +export const getUserBetHistory = async ( + req: Request, + res: Response> +) => { + const userId = (req.user as User).id; + + // Parse pagination parameters from query + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 10; + + // Get paginated bets + const paginatedBets = await getUserBets({ userId, page, pageSize }); + + res + .status(StatusCodes.OK) + .json(new ApiResponse(StatusCodes.OK, paginatedBets)); +}; diff --git a/apps/api/src/features/user/user.router.ts b/apps/api/src/features/user/user.router.ts new file mode 100644 index 0000000..57afc3d --- /dev/null +++ b/apps/api/src/features/user/user.router.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../../middlewares/auth.middleware'; +import { + getBalance, + rotateSeed, + getProvablyFairState, + getRevealedServerSeed, + getUserBetHistory, +} from './user.controller'; + +const router: Router = Router(); + +router.get('/balance', isAuthenticated, getBalance); +router.post('/rotate-seeds', isAuthenticated, rotateSeed); +router.get('/provably-fair-state', isAuthenticated, getProvablyFairState); +router.get( + '/unhash-server-seed/:hashedServerSeed', + isAuthenticated, + getRevealedServerSeed +); +router.get('/bets', isAuthenticated, getUserBetHistory); + +export default router; diff --git a/apps/api/src/features/user/user.service.ts b/apps/api/src/features/user/user.service.ts new file mode 100644 index 0000000..2401533 --- /dev/null +++ b/apps/api/src/features/user/user.service.ts @@ -0,0 +1,281 @@ +import type { Bet, Prisma, ProvablyFairState, User } from '@prisma/client'; +import db from '@repo/db'; +import type { ProvablyFairStateResponse } from '@repo/common/types'; +import { + getGeneratedFloats, + getHashedSeed, + getHmacSeed, +} from '@repo/common/game-utils/provably-fair/utils.js'; +import { BadRequestError } from '../../errors'; +import { generateClientSeed, generateServerSeed } from './user.utils'; + +export class UserInstance { + constructor( + private user: User, + private provablyFairState: ProvablyFairState + ) {} + + setBalance(amount: string) { + // Ensure the balance is stored as a string + this.user.balance = amount; + } + + getUser() { + return this.user; + } + + getUserId() { + return this.user.id; + } + + async rotateSeed(clientSeed: string): Promise { + const newServerSeed = this.generateNextServerSeed(); + const hashedServerSeed = getHashedSeed(newServerSeed); + + const result = await db.$transaction(async tx => { + // Mark current seed as revealed + await tx.provablyFairState.update({ + where: { id: this.provablyFairState.id }, + data: { revealed: true }, + }); + + // Create new seeds + const updated = await tx.provablyFairState.create({ + data: { + serverSeed: newServerSeed, + hashedServerSeed, + clientSeed, + revealed: false, + nonce: 0, + userId: this.user.id, + }, + }); + + // Update instance state + this.provablyFairState = updated; + + return { + clientSeed, + hashedServerSeed: this.getHashedServerSeed(), + hashedNextServerSeed: this.getHashedNextServerSeed(), + nonce: updated.nonce, + }; + }); + + return result; + } + + getBalance(): string { + return this.user.balance; + } + + // Convert balance to number for calculations when needed + getBalanceAsNumber(): number { + return parseInt(this.user.balance, 10); + } + + async updateNonce(tx: Prisma.TransactionClient) { + await tx.provablyFairState.update({ + where: { id: this.provablyFairState.id }, + data: { nonce: this.provablyFairState.nonce }, + }); + } + + getProvablyFairStateId() { + return this.provablyFairState.id; + } + + getServerSeed() { + return this.provablyFairState.serverSeed; + } + + getClientSeed() { + return this.provablyFairState.clientSeed; + } + + getNonce() { + return this.provablyFairState.nonce; + } + + getHashedServerSeed() { + return getHashedSeed(this.provablyFairState.serverSeed); + } + + getHashedNextServerSeed(): string { + const nextServerSeed = this.generateNextServerSeed(); + return getHashedSeed(nextServerSeed); + } + + private generateNextServerSeed(): string { + return getHmacSeed(this.provablyFairState.serverSeed, 'next-seed'); + } + + generateFloats(count: number): number[] { + this.provablyFairState.nonce += 1; + return getGeneratedFloats({ + count, + seed: this.provablyFairState.serverSeed, + message: `${this.provablyFairState.clientSeed}:${this.provablyFairState.nonce}`, + }); + } + + // Function to get a revealed server seed by its hash + async getRevealedServerSeedByHash( + hashedServerSeed: string + ): Promise { + const revealedState = await db.provablyFairState.findFirst({ + where: { + hashedServerSeed, + revealed: true, + userId: this.user.id, + }, + }); + + if (!revealedState) { + return null; + } + + return revealedState.serverSeed; + } +} + +class UserManager { + private static instance: UserManager | undefined; + private users = new Map(); + + static getInstance() { + if (!UserManager.instance) { + UserManager.instance = new UserManager(); + } + return UserManager.instance; + } + + async getUser(userId: string): Promise { + if (!this.users.has(userId)) { + const user = await db.user.findUnique({ + where: { id: userId }, + include: { + provablyFairStates: { + where: { + revealed: false, + }, + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }); + if (!user) { + throw new BadRequestError('User not found'); + } + if (!user.provablyFairStates[0]) { + const serverSeed = generateServerSeed(); + const clientSeed = generateClientSeed(); + const hashedServerSeed = getHashedSeed(serverSeed); + // Create initial provably fair state if it doesn't exist + const provablyFairState = await db.provablyFairState.create({ + data: { + userId: user.id, + serverSeed, + clientSeed, + hashedServerSeed, + nonce: 0, + revealed: false, + }, + }); + user.provablyFairStates = [provablyFairState]; + } + this.users.set( + userId, + new UserInstance(user, user.provablyFairStates[0]) + ); + } + const user = this.users.get(userId); + if (!user) { + throw new BadRequestError('User not found in manager'); + } + return user; + } + + async getActiveBets(userId: string): Promise { + const bets = await db.bet.findMany({ + where: { userId, active: true }, + }); + return bets; + } + + removeUser(userId: string) { + this.users.delete(userId); + } +} + +export const userManager = UserManager.getInstance(); + +export const getUserBets = async ({ + userId, + page = 1, + pageSize = 10, +}: { + userId: string; + page?: number; + pageSize?: number; +}) => { + // Ensure valid pagination parameters + const validPage = Math.max(1, page); + const validPageSize = Math.min(100, Math.max(1, pageSize)); + + // Get total count for pagination + const totalCount = await db.bet.count({ + where: { + userId, + }, + }); + + // Get paginated bets + const bets = await db.bet.findMany({ + where: { + userId, + active: false, + }, + orderBy: { + createdAt: 'desc', + }, + include: { + user: { + select: { + id: true, + name: true, + }, + }, + }, + skip: (validPage - 1) * validPageSize, + take: validPageSize, + }); + + // Calculate pagination metadata + const totalPages = Math.ceil(totalCount / validPageSize); + const hasNextPage = validPage < totalPages; + const hasPreviousPage = validPage > 1; + + return { + bets: bets.map(bet => ({ + // Format betId as a 12-digit string with leading zeros + betId: bet.betId.toString().padStart(12, '0'), + game: bet.game, + date: bet.createdAt, + betAmount: bet.betAmount / 100, + payoutMultiplier: bet.payoutAmount / bet.betAmount, + payout: bet.payoutAmount / 100, + id: bet.id, + })), + pagination: { + page: validPage, + pageSize: validPageSize, + totalCount, + totalPages, + hasNextPage, + hasPreviousPage, + }, + }; +}; diff --git a/apps/api/src/features/user/user.utils.ts b/apps/api/src/features/user/user.utils.ts new file mode 100644 index 0000000..307a219 --- /dev/null +++ b/apps/api/src/features/user/user.utils.ts @@ -0,0 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +export const generateClientSeed = () => { + return randomBytes(32).toString('hex'); +}; + +export const generateServerSeed = () => { + return randomBytes(32).toString('hex'); +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..d7f0c68 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,17 @@ +import { createServer } from './server'; +import db from '@repo/db'; + +const port = process.env.PORT || 5000; +const app = createServer(); + +const server = app.listen(port); + +const gracefulShutdown = async () => { + server.close(async () => { + await db.$disconnect(); + process.exit(); + }); +}; + +process.on('SIGTERM', gracefulShutdown); +process.on('SIGINT', gracefulShutdown); diff --git a/apps/api/src/middlewares/auth.middleware.ts b/apps/api/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..be1c8ab --- /dev/null +++ b/apps/api/src/middlewares/auth.middleware.ts @@ -0,0 +1,16 @@ +import type { NextFunction, Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { UnAuthenticatedError } from '../errors'; + +export const isAuthenticated = ( + req: Request, + res: Response, + next: NextFunction +): void => { + if (!req.isAuthenticated()) { + throw new UnAuthenticatedError( + 'You must be logged in to access this resource' + ); + } + next(); +}; diff --git a/apps/api/src/middlewares/bet.middleware.ts b/apps/api/src/middlewares/bet.middleware.ts new file mode 100644 index 0000000..b059f37 --- /dev/null +++ b/apps/api/src/middlewares/bet.middleware.ts @@ -0,0 +1,169 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { User, Game } from '@prisma/client'; +import { BadRequestError, UnAuthenticatedError } from '../errors'; +import { validateBetAmount } from '../utils/bet.utils'; +import { userManager } from '../features/user/user.service'; + +declare module 'express' { + interface Request { + validatedBet?: { + betAmountInCents: number; + userBalanceInCents: number; + userInstance: any; + }; + isMyBet?: boolean; + } +} + +export interface BetValidationMiddlewareOptions { + game?: Game; + betAmountField?: string; +} + +/** + * Middleware to validate bet amount and user balance + */ +export const validateBet = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const user = req.user as User; + if (!user) { + throw new UnAuthenticatedError('Authentication required'); + } + const betAmount = req.body.betAmount; + + if (!betAmount || typeof betAmount !== 'number') { + throw new BadRequestError(`betAmount is required and must be a number`); + } + + // Get user instance + const userInstance = await userManager.getUser(user.id); + + // Validate bet amount and balance + const validatedBet = await validateBetAmount({ + betAmount, + userInstance, + }); + // Attach validated data to request + req.validatedBet = { ...validatedBet, userInstance }; + + next(); +}; + +/** + * Middleware to check if user has any active bets for a specific game + */ +export const checkActiveGame = (game: Game) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user as User; + if (!user) { + throw new UnAuthenticatedError('Authentication required'); + } + + // This would need to be implemented based on your game managers + // For now, this is a placeholder + // const hasActiveBet = await checkUserActiveGame(user.id, game); + // if (hasActiveBet) { + // throw new BadRequestError(`You already have an active ${game} game`); + // } + + next(); + } catch (error) { + next(error); + } + }; +}; + +/** + * Middleware to validate request body against a schema + */ +export const validateSchema = (schema: any) => { + return (req: Request, res: Response, next: NextFunction) => { + try { + const result = schema.safeParse(req.body); + if (!result.success) { + throw new BadRequestError(result.error.message); + } + + // Replace body with validated data + req.body = result.data; + next(); + } catch (error) { + next(error); + } + }; +}; + +/** + * Middleware to validate game-specific constraints + */ +export const validateGameConstraints = (constraints: { + maxBetAmount?: number; + minBetAmount?: number; + allowedRiskLevels?: string[]; + maxSelections?: number; + minSelections?: number; +}) => { + return (req: Request, res: Response, next: NextFunction) => { + try { + const { betAmount, risk, selectedTiles, ...rest } = req.body; + + // Validate bet amount constraints + if (constraints.maxBetAmount && betAmount > constraints.maxBetAmount) { + throw new BadRequestError( + `Maximum bet amount is $${constraints.maxBetAmount}` + ); + } + + if (constraints.minBetAmount && betAmount < constraints.minBetAmount) { + throw new BadRequestError( + `Minimum bet amount is $${constraints.minBetAmount}` + ); + } + + // Validate risk levels (for games like Keno) + if ( + constraints.allowedRiskLevels && + risk && + !constraints.allowedRiskLevels.includes(risk) + ) { + throw new BadRequestError( + `Invalid risk level. Allowed: ${constraints.allowedRiskLevels.join(', ')}` + ); + } + + // Validate selections (for games like Keno, Mines) + if (selectedTiles && Array.isArray(selectedTiles)) { + if ( + constraints.maxSelections && + selectedTiles.length > constraints.maxSelections + ) { + throw new BadRequestError( + `Maximum ${constraints.maxSelections} selections allowed` + ); + } + + if ( + constraints.minSelections && + selectedTiles.length < constraints.minSelections + ) { + throw new BadRequestError( + `Minimum ${constraints.minSelections} selections required` + ); + } + } + + next(); + } catch (error) { + next(error); + } + }; +}; + +export const verifyMe = (req: Request, res: Response, next: NextFunction) => { + req.isMyBet = req.isAuthenticated(); + next(); +}; diff --git a/apps/api/src/middlewares/error-handler.ts b/apps/api/src/middlewares/error-handler.ts new file mode 100644 index 0000000..13a4c01 --- /dev/null +++ b/apps/api/src/middlewares/error-handler.ts @@ -0,0 +1,27 @@ +import { ApiResponse } from '@repo/common/types'; +import type { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +interface CustomError { + statusCode: number; + message: string; + data?: unknown; +} + +export const errorHandlerMiddleware = ( + err: Error | CustomError, + _: Request, + res: Response, + __: NextFunction +) => { + const defaultError: CustomError = { + statusCode: + (err as CustomError).statusCode || StatusCodes.INTERNAL_SERVER_ERROR, + message: + (err as CustomError).message || 'Something went wrong, try again later', + }; + + return res + .status(defaultError.statusCode) + .json(new ApiResponse(defaultError.statusCode, {}, defaultError.message)); +}; diff --git a/apps/api/src/middlewares/not-found.ts b/apps/api/src/middlewares/not-found.ts new file mode 100644 index 0000000..b83a647 --- /dev/null +++ b/apps/api/src/middlewares/not-found.ts @@ -0,0 +1,11 @@ +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { ApiResponse } from '@repo/common/types'; + +const notFoundMiddleware = (_: Request, res: Response) => { + res + .status(StatusCodes.BAD_REQUEST) + .send(new ApiResponse(StatusCodes.BAD_REQUEST, {}, 'Route does not exist')); +}; + +export default notFoundMiddleware; diff --git a/apps/api/src/middlewares/rateLimit.middleware.ts b/apps/api/src/middlewares/rateLimit.middleware.ts new file mode 100644 index 0000000..5574abd --- /dev/null +++ b/apps/api/src/middlewares/rateLimit.middleware.ts @@ -0,0 +1,64 @@ +import type { Request, Response } from 'express'; +import type { User } from '@prisma/client'; +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; + +// NOTE: Using the default in-memory store from `express-rate-limit`. +// This is intended for single-instance or development environments only. +// For production with multiple instances, replace with a shared store (Redis). + +/** + * Rate limiting middleware for betting + */ +export const rateLimitBets = (options: { + maxBetsPerMinute?: number; + maxBetsPerHour?: number; +}) => { + const max = options.maxBetsPerMinute || 100; + + const limiter = rateLimit({ + // Rate limiter configuration + windowMs: 15 * 60 * 1000, // 15 minutes + max, + keyGenerator: (req: Request) => + String((req.user as User)?.id || ipKeyGenerator(req.ip || '')), + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + + // Using default in-memory store (suitable for single-instance / dev only). + + handler: (req: Request, res: Response) => { + res.status(429).json({ + message: 'Too many requests - bets limit exceeded', + }); + }, + }); + + return limiter; +}; + +/** + * General purpose rate limiter factory for non-bet endpoints. + * Defaults to 15 minute window with 100 requests per window. + */ +export const rateLimitRequests = (options?: { + windowMs?: number; + max?: number; + message?: string; +}) => { + const windowMs = options?.windowMs ?? 15 * 60 * 1000; + const max = options?.max ?? 100; + const message = options?.message ?? 'Too many requests'; + + return rateLimit({ + windowMs, + max, + keyGenerator: (req: Request) => + String((req.user as User)?.id || ipKeyGenerator(req.ip || '')), + standardHeaders: true, + legacyHeaders: false, + // Using default in-memory store (suitable for single-instance / dev only). + handler: (_req: Request, res: Response) => { + res.status(429).json({ message }); + }, + }); +}; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 0000000..a4dfc32 --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,5 @@ +import authRouter from '../features/auth/auth.router'; +import gameRouter from '../features/games/games.router'; +import userRouter from '../features/user/user.router'; + +export { authRouter, gameRouter, userRouter }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..cb27b92 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,72 @@ +import 'express-async-errors'; +import 'dotenv/config'; + +import { json, urlencoded } from 'body-parser'; +import express, { type Express } from 'express'; +import morgan from 'morgan'; +import cors from 'cors'; +import session from 'express-session'; +import passport from 'passport'; +import { StatusCodes } from 'http-status-codes'; +import { authRouter, gameRouter, userRouter } from './routes'; +import './config/passport'; +import notFoundMiddleware from './middlewares/not-found'; +import { errorHandlerMiddleware } from './middlewares/error-handler'; +import { rateLimitRequests } from './middlewares/rateLimit.middleware'; + +export const createServer = (): Express => { + const app = express(); + + // Trust proxy (CRITICAL for Nginx reverse proxy) + app.set('trust proxy', 1); + + app + .disable('x-powered-by') + .use(morgan('dev')) + .use(urlencoded({ extended: true })) + .use(json()) + .use( + cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', // Use env variable with fallback + credentials: true, // Allow cookies and other credentials + }) + ) + .use( + session({ + secret: process.env.COOKIE_SECRET || 'secr3T', + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + maxAge: 2 * 24 * 60 * 60 * 1000, // 2 days + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + domain: + process.env.NODE_ENV === 'production' + ? process.env.COOKIE_DOMAIN + : undefined, + }, + resave: false, + saveUninitialized: false, + }) + ) + .use(passport.initialize()) + .use(passport.session()) + // Global lightweight rate limiter to protect endpoints + .use( + rateLimitRequests({ + windowMs: 60 * 1000, + max: 300, + message: 'Too many requests - global limit', + }) + ) + .get('/health', (_, res) => { + return res.status(StatusCodes.OK).json({ ok: true }); + }) + .use('/api/v1/auth', authRouter) + .use('/api/v1/games', gameRouter) + .use('/api/v1/user', userRouter); + + app.use(notFoundMiddleware); + app.use(errorHandlerMiddleware); + + return app; +}; diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts new file mode 100644 index 0000000..09e7082 --- /dev/null +++ b/apps/api/src/types.ts @@ -0,0 +1,6 @@ +import type { Request } from 'express'; +import type { User } from '@prisma/client'; + +export interface AuthenticatedRequest extends Request { + user?: User; +} diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts new file mode 100644 index 0000000..de35de4 --- /dev/null +++ b/apps/api/src/types/index.ts @@ -0,0 +1,6 @@ +import type { User } from '@prisma/client'; +import type { Request } from 'express'; + +export interface AuthenticatedRequest extends Request { + user: User | undefined; +} diff --git a/apps/api/src/utils/bet.utils.ts b/apps/api/src/utils/bet.utils.ts new file mode 100644 index 0000000..1586171 --- /dev/null +++ b/apps/api/src/utils/bet.utils.ts @@ -0,0 +1,240 @@ +import type { User, Game, Bet, PrismaClient } from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import db, { PrismaTransactionalClient } from '@repo/db'; +import { BadRequestError } from '../errors'; +import { userManager, UserInstance } from '../features/user/user.service'; + +export interface BetValidationOptions { + betAmount: number; + userInstance: UserInstance; +} + +export interface UpdateBetTransactionOptions { + betId: string; + data: any; +} + +export interface BetTransactionOptions { + active?: boolean; + betAmount: number; + game: Game; + gameState: any; + payoutAmount?: number; + userInstance: UserInstance; +} + +export interface BetTransactionResult { + bet: Bet; + betId: string; + newBalance: string; +} + +/** + * Validates bet amount and user balance + */ +export const validateBetAmount = async ({ + betAmount, + userInstance, +}: BetValidationOptions): Promise<{ + betAmountInCents: number; + userBalanceInCents: number; +}> => { + // Validate bet amount range + if (betAmount <= 0) { + throw new BadRequestError('Bet amount must be greater than 0'); + } + + // Convert to cents for precision + const betAmountInCents = Math.round(betAmount * 100); + const userBalanceInCents = userInstance.getBalanceAsNumber(); + + // Check sufficient balance + if (userBalanceInCents < betAmountInCents) { + throw new BadRequestError('Insufficient balance'); + } + + return { + betAmountInCents, + userBalanceInCents, + }; +}; + +const updateBalance = async ({ + betAmount, + payoutAmount, + tx, + userInstance, +}: { + betAmount: number; + payoutAmount: number; + tx: PrismaTransactionalClient; + userInstance: UserInstance; +}): Promise => { + const userBalance = userInstance.getBalanceAsNumber(); + const balanceChange = payoutAmount - betAmount; + const newBalance = (userBalance + balanceChange).toString(); + + if (balanceChange === 0) { + return userBalance.toString(); + } + + const userWithNewBalance = await tx.user.update({ + where: { id: userInstance.getUserId() }, + data: { + balance: newBalance, + }, + }); + + return userWithNewBalance.balance; +}; + +/** + * Creates a bet transaction and updates user balance + */ +export const createBetTransaction = async ({ + active = false, + betAmount, + game, + gameState, + payoutAmount = 0, + userInstance, +}: BetTransactionOptions): Promise => { + const { balance, bet } = await db.$transaction(async tx => { + // Create bet record + const bet = await tx.bet.create({ + data: { + active, + betAmount, + betNonce: userInstance.getNonce(), + game, + payoutAmount, + provablyFairStateId: userInstance.getProvablyFairStateId(), + state: gameState, + userId: userInstance.getUserId(), + }, + }); + + // Update user nonce + await userInstance.updateNonce(tx); + + // Update user balance + const balance = await updateBalance({ + tx, + userInstance, + payoutAmount, + betAmount, + }); + + return { + balance, + bet, + }; + }); + + // Update user instance with new balance + userInstance.setBalance(balance); + + return { + betId: bet.id, + bet, + newBalance: balance, + }; +}; + +export const editBetAndUpdateBalance = async ({ + betAmount, + betId, + data, + payoutAmount = 0, + userInstance, +}: UpdateBetTransactionOptions & + Omit) => { + const { balance, bet } = await db.$transaction(async tx => { + // Create bet record + const bet = await tx.bet.update({ + where: { id: betId }, + data, + }); + + // Update user nonce + await userInstance.updateNonce(tx); + + // Update user balance + const balance = await updateBalance({ + betAmount, + payoutAmount, + tx, + userInstance, + }); + + return { + balance, + bet, + }; + }); + + // Update user instance with new balance + userInstance.setBalance(balance); + + return { + bet, + betId: bet.id, + newBalance: balance, + }; +}; + +/** + * Validates and creates a complete bet transaction + */ +export const validateAndCreateBet = async ({ + active = false, + betAmount, + game, + gameState, + payoutAmount = 0, + userInstance, +}: BetValidationOptions & + Omit< + BetTransactionOptions, + 'betAmount' | 'userId' + >): Promise => { + // Validate bet + await validateBetAmount({ betAmount, userInstance }); + + // Create transaction + return createBetTransaction({ + active, + betAmount, + game, + gameState, + payoutAmount, + userInstance, + }); +}; + +/** + * Converts a major currency unit (e.g., dollars, euros) to its minor unit (e.g., cents, pence). + * Use for currency-agnostic calculations. + */ +export const amountToMinor = (amount: number): number => { + return Math.round(amount * 100); +}; + +/** + * Converts a minor currency unit (e.g., cents, pence) to its major unit (e.g., dollars, euros). + * Use for currency-agnostic calculations. + */ +export const minorToAmount = (minor: number): number => { + return minor / 100; +}; + +/** + * Calculates payout multiplier + */ +export const calculatePayoutMultiplier = ( + payoutInCents: number, + betAmountInCents: number +): number => { + if (betAmountInCents === 0) return 0; + return payoutInCents / betAmountInCents; +}; diff --git a/apps/api/src/utils/game.utils.ts b/apps/api/src/utils/game.utils.ts new file mode 100644 index 0000000..34287c9 --- /dev/null +++ b/apps/api/src/utils/game.utils.ts @@ -0,0 +1,165 @@ +import type { Game } from '@prisma/client'; +import { + BetTransactionResult, + minorToAmount, + validateAndCreateBet, +} from '../utils/bet.utils'; +import { ApiResponse } from '@repo/common/types'; +import { StatusCodes } from 'http-status-codes'; +import type { Response } from 'express'; + +export interface GameResult { + gameState: any; + payout: number; + payoutMultiplier: number; +} + +export interface GameResponse { + id: string; + state: any; + payout: number; + payoutMultiplier: number; + balance: number; + betAmount: number; +} + +/** + * Base class for game services + */ +export abstract class BaseGameService { + protected game: Game; + + constructor(game: Game) { + this.game = game; + } + + /** + * Process a complete game round + */ + async processGameRound({ + userId, + betAmount, + gameInput, + userInstance, + }: { + userId: string; + betAmount: number; + gameInput: any; + userInstance: any; + }): Promise { + // 1. Generate game result + const result = await this.generateGameResult(gameInput, userInstance); + + // 2. Create bet transaction + const transaction = await validateAndCreateBet({ + betAmount, + userInstance, + game: this.game, + gameState: result.gameState, + payoutAmount: result.payout, + active: false, + }); + + // 3. Return formatted response + return { + id: transaction.betId, + state: result.gameState, + payout: result.payout, + payoutMultiplier: result.payoutMultiplier, + balance: parseFloat(transaction.newBalance) / 100, // Convert to dollars + betAmount, + }; + } + + /** + * Send standardized game response + */ + sendGameResponse(res: Response, data: GameResponse): void { + res.status(StatusCodes.OK).json(new ApiResponse(StatusCodes.OK, data)); + } + + /** + * Abstract method to be implemented by each game + */ + protected abstract generateGameResult( + gameInput: any, + userInstance: any + ): Promise; +} + +/** + * Factory function to create game-specific constraints + */ +export const createGameConstraints = (game: Game) => { + const constraints = { + dice: { + minBetAmount: 0.01, + maxBetAmount: 1000, + }, + keno: { + minBetAmount: 0.01, + maxBetAmount: 1000, + allowedRiskLevels: ['low', 'medium', 'high'], + maxSelections: 10, + minSelections: 1, + }, + roulette: { + minBetAmount: 0.01, + maxBetAmount: 500, + }, + blackjack: { + minBetAmount: 0.01, + maxBetAmount: 1000, + }, + mines: { + minBetAmount: 0.01, + maxBetAmount: 1000, + maxSelections: 24, + minSelections: 1, + }, + }; + + return constraints[game] || {}; +}; + +/** + * Common validation for game inputs + */ +export const validateGameInput = (game: Game, input: any): void => { + switch (game) { + case 'dice': + if (!input.target || !input.condition) { + throw new Error('Target and condition are required for dice game'); + } + break; + case 'keno': + if (!input.selectedTiles || !Array.isArray(input.selectedTiles)) { + throw new Error('Selected tiles are required for keno game'); + } + break; + case 'roulette': + if (!input.bets || !Array.isArray(input.bets)) { + throw new Error('Bets are required for roulette game'); + } + break; + // Add more game validations as needed + } +}; + +/** + * Common response formatter for all games + */ +export const formatGameResponse = ( + result: GameResult, + betTransaction: BetTransactionResult, + betAmount: number +): GameResponse => { + return { + id: betTransaction.betId, + state: result.gameState, + payout: result.payout, + payoutMultiplier: result.payoutMultiplier, + balance: minorToAmount(parseFloat(betTransaction.newBalance)), + betAmount, + }; +}; diff --git a/apps/api/src/utils/numbers.ts b/apps/api/src/utils/numbers.ts new file mode 100644 index 0000000..86193f0 --- /dev/null +++ b/apps/api/src/utils/numbers.ts @@ -0,0 +1,13 @@ +/** + * Checks if a number is within a specified range. + * + * @param number - The number to check. + * @param min - The minimum value of the range. + * @param max - The maximum value of the range. + * @returns - Returns true if the number is within the range, otherwise false. + */ +const isNumberInRange = (number: number, min: number, max: number) => { + return number >= min && number <= max; +}; + +export { isNumberInRange }; diff --git a/apps/api/src/utils/redisClient.ts b/apps/api/src/utils/redisClient.ts new file mode 100644 index 0000000..9af6ba0 --- /dev/null +++ b/apps/api/src/utils/redisClient.ts @@ -0,0 +1,5 @@ +import Redis from 'ioredis'; + +const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379'); + +export default redis; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..4107ff0 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "moduleResolution": "node16", + "module": "Node16", + "lib": ["ES2015"], + "outDir": "./dist" + }, + "exclude": ["node_modules"], + "include": ["."] +} diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts new file mode 100644 index 0000000..ba655f6 --- /dev/null +++ b/apps/api/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, type Options } from 'tsup'; + +export default defineConfig((options: Options) => ({ + entryPoints: ['src/index.ts'], + clean: true, + format: ['cjs'], + ...options, +})); diff --git a/apps/api/turbo.json b/apps/api/turbo.json new file mode 100644 index 0000000..52e8c76 --- /dev/null +++ b/apps/api/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "//" + ], + "tasks": { + "build": { + "outputs": [ + "dist/**" + ] + } + } +} diff --git a/apps/frontend/.eslintrc.js b/apps/frontend/.eslintrc.js new file mode 100644 index 0000000..44a3f93 --- /dev/null +++ b/apps/frontend/.eslintrc.js @@ -0,0 +1,23 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: ['@repo/eslint-config/react.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + }, + ignorePatterns: ['tailwind.config.js'], + rules: { + 'unicorn/filename-case': [ + 'error', + { + cases: { + kebabCase: true, + pascalCase: true, + camelCase: true, + }, + ignore: ['FeaturedGames.tsx'], + }, + ], + 'eslint-comments/require-description': 'off', + }, +}; diff --git a/apps/frontend/.prettier.config.js b/apps/frontend/.prettier.config.js new file mode 100644 index 0000000..27fe20a --- /dev/null +++ b/apps/frontend/.prettier.config.js @@ -0,0 +1,15 @@ +// prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs + +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + trailingComma: 'es5', + tabWidth: 4, + semi: false, + singleQuote: true, + plugins: [require('prettier-plugin-tailwindcss')], +}; + +export default config; diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 0000000..d296ddc --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..34f9204 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,26 @@ + + + + + + Sim Casino + + + + + + + + + + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..4e83ccc --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,71 @@ +{ + "name": "frontend", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "vite build", + "clean": "rm -rf dist", + "dev": "vite --host 0.0.0.0 --port 3000 --clearScreen false", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "add:ui": "pnpm dlx shadcn@latest add", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@icons-pack/react-simple-icons": "^10.0.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-tooltip": "^1.1.8", + "@repo/common": "workspace:*", + "@repo/db": "workspace:*", + "@tanstack/react-query": "^5.53.1", + "@tanstack/react-router": "^1.51.7", + "@tanstack/react-table": "^8.21.2", + "axios": "^1.7.6", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "crypto-browserify": "^3.12.0", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.5.2", + "lodash": "^4.17.21", + "lucide-react": "^0.436.0", + "motion": "^12.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hot-toast": "^2.5.2", + "react-spinners": "^0.15.0", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@tanstack/router-devtools": "^1.51.7", + "@tanstack/router-plugin": "^1.51.6", + "@types/lodash": "^4.17.7", + "@types/react": "^18.2.62", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.4.10", + "typescript": "^5.3.3", + "vite": "^5.1.4", + "vite-plugin-node-polyfills": "^0.23.0", + "vite-tsconfig-paths": "^5.0.1" + } +} diff --git a/frontend/postcss.config.js b/apps/frontend/postcss.config.js similarity index 73% rename from frontend/postcss.config.js rename to apps/frontend/postcss.config.js index 2e7af2b..12a703d 100644 --- a/frontend/postcss.config.js +++ b/apps/frontend/postcss.config.js @@ -1,6 +1,6 @@ -export default { +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/apps/frontend/public/apple-touch-icon.png b/apps/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..aa198d3 Binary files /dev/null and b/apps/frontend/public/apple-touch-icon.png differ diff --git a/apps/frontend/public/favicon-96x96.png b/apps/frontend/public/favicon-96x96.png new file mode 100644 index 0000000..526e4d2 Binary files /dev/null and b/apps/frontend/public/favicon-96x96.png differ diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico new file mode 100644 index 0000000..1ce4764 Binary files /dev/null and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg new file mode 100644 index 0000000..c42b7ce --- /dev/null +++ b/apps/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/games/blackjack/background.svg b/apps/frontend/public/games/blackjack/background.svg new file mode 100644 index 0000000..ee15cd5 --- /dev/null +++ b/apps/frontend/public/games/blackjack/background.svg @@ -0,0 +1,112 @@ + + + + +Untitled-1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/public/games/blackjack/double.svg b/apps/frontend/public/games/blackjack/double.svg new file mode 100644 index 0000000..6702871 --- /dev/null +++ b/apps/frontend/public/games/blackjack/double.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/games/blackjack/hit.svg b/apps/frontend/public/games/blackjack/hit.svg new file mode 100644 index 0000000..e3834e3 --- /dev/null +++ b/apps/frontend/public/games/blackjack/hit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/games/blackjack/split.svg b/apps/frontend/public/games/blackjack/split.svg new file mode 100644 index 0000000..53c1385 --- /dev/null +++ b/apps/frontend/public/games/blackjack/split.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/games/blackjack/stand.svg b/apps/frontend/public/games/blackjack/stand.svg new file mode 100644 index 0000000..1741dd4 --- /dev/null +++ b/apps/frontend/public/games/blackjack/stand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/games/cards/C.png b/apps/frontend/public/games/cards/C.png new file mode 100644 index 0000000..ef50e0c Binary files /dev/null and b/apps/frontend/public/games/cards/C.png differ diff --git a/apps/frontend/public/games/cards/D.png b/apps/frontend/public/games/cards/D.png new file mode 100644 index 0000000..aaa7899 Binary files /dev/null and b/apps/frontend/public/games/cards/D.png differ diff --git a/apps/frontend/public/games/cards/H.png b/apps/frontend/public/games/cards/H.png new file mode 100644 index 0000000..309fc41 Binary files /dev/null and b/apps/frontend/public/games/cards/H.png differ diff --git a/apps/frontend/public/games/cards/S.png b/apps/frontend/public/games/cards/S.png new file mode 100644 index 0000000..c58c0f3 Binary files /dev/null and b/apps/frontend/public/games/cards/S.png differ diff --git a/apps/frontend/public/games/cards/back.png b/apps/frontend/public/games/cards/back.png new file mode 100644 index 0000000..25efedc Binary files /dev/null and b/apps/frontend/public/games/cards/back.png differ diff --git a/apps/frontend/public/games/dice/loading-dice.png b/apps/frontend/public/games/dice/loading-dice.png new file mode 100644 index 0000000..c34cc95 Binary files /dev/null and b/apps/frontend/public/games/dice/loading-dice.png differ diff --git a/apps/frontend/public/games/dice/result-dice.png b/apps/frontend/public/games/dice/result-dice.png new file mode 100644 index 0000000..8e68a59 Binary files /dev/null and b/apps/frontend/public/games/dice/result-dice.png differ diff --git a/apps/frontend/public/games/illustration/blackjack.png b/apps/frontend/public/games/illustration/blackjack.png new file mode 100644 index 0000000..59b9ce2 Binary files /dev/null and b/apps/frontend/public/games/illustration/blackjack.png differ diff --git a/apps/frontend/public/games/illustration/dice.png b/apps/frontend/public/games/illustration/dice.png new file mode 100644 index 0000000..1645a60 Binary files /dev/null and b/apps/frontend/public/games/illustration/dice.png differ diff --git a/apps/frontend/public/games/illustration/keno.png b/apps/frontend/public/games/illustration/keno.png new file mode 100644 index 0000000..d1f08b5 Binary files /dev/null and b/apps/frontend/public/games/illustration/keno.png differ diff --git a/apps/frontend/public/games/illustration/mines.png b/apps/frontend/public/games/illustration/mines.png new file mode 100644 index 0000000..c46a04a Binary files /dev/null and b/apps/frontend/public/games/illustration/mines.png differ diff --git a/apps/frontend/public/games/illustration/roulette.png b/apps/frontend/public/games/illustration/roulette.png new file mode 100644 index 0000000..f61065c Binary files /dev/null and b/apps/frontend/public/games/illustration/roulette.png differ diff --git a/apps/frontend/public/games/keno/gem.svg b/apps/frontend/public/games/keno/gem.svg new file mode 100644 index 0000000..dc3ba2e --- /dev/null +++ b/apps/frontend/public/games/keno/gem.svg @@ -0,0 +1 @@ +gem \ No newline at end of file diff --git a/apps/frontend/public/games/mines/bomb-effect.webp b/apps/frontend/public/games/mines/bomb-effect.webp new file mode 100644 index 0000000..cae8da8 Binary files /dev/null and b/apps/frontend/public/games/mines/bomb-effect.webp differ diff --git a/apps/frontend/public/games/mines/bomb.png b/apps/frontend/public/games/mines/bomb.png new file mode 100644 index 0000000..9c7e777 Binary files /dev/null and b/apps/frontend/public/games/mines/bomb.png differ diff --git a/apps/frontend/public/games/mines/diamond.svg b/apps/frontend/public/games/mines/diamond.svg new file mode 100644 index 0000000..e9ce8c8 --- /dev/null +++ b/apps/frontend/public/games/mines/diamond.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/games/mines/mine.svg b/apps/frontend/public/games/mines/mine.svg new file mode 100644 index 0000000..37ed06d --- /dev/null +++ b/apps/frontend/public/games/mines/mine.svg @@ -0,0 +1 @@ +bomb diff --git a/apps/frontend/public/games/roulette/chip-bg-img.svg b/apps/frontend/public/games/roulette/chip-bg-img.svg new file mode 100644 index 0000000..d64955c --- /dev/null +++ b/apps/frontend/public/games/roulette/chip-bg-img.svg @@ -0,0 +1 @@ +background \ No newline at end of file diff --git a/apps/frontend/public/games/roulette/loading-roulette.png b/apps/frontend/public/games/roulette/loading-roulette.png new file mode 100644 index 0000000..8eadcc3 Binary files /dev/null and b/apps/frontend/public/games/roulette/loading-roulette.png differ diff --git a/apps/frontend/public/games/roulette/roulette-dolly.svg b/apps/frontend/public/games/roulette/roulette-dolly.svg new file mode 100644 index 0000000..d8fbf36 --- /dev/null +++ b/apps/frontend/public/games/roulette/roulette-dolly.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/frontend/public/games/roulette/roulette-wheel-arrow.svg b/apps/frontend/public/games/roulette/roulette-wheel-arrow.svg new file mode 100644 index 0000000..300e8d3 --- /dev/null +++ b/apps/frontend/public/games/roulette/roulette-wheel-arrow.svg @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/apps/frontend/public/sim-casino-logo.webp b/apps/frontend/public/sim-casino-logo.webp new file mode 100644 index 0000000..1dd0161 Binary files /dev/null and b/apps/frontend/public/sim-casino-logo.webp differ diff --git a/apps/frontend/public/sim-casino-mini-logo.png b/apps/frontend/public/sim-casino-mini-logo.png new file mode 100644 index 0000000..07930df Binary files /dev/null and b/apps/frontend/public/sim-casino-mini-logo.png differ diff --git a/apps/frontend/public/site.webmanifest b/apps/frontend/public/site.webmanifest new file mode 100644 index 0000000..729b4d3 --- /dev/null +++ b/apps/frontend/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "SimCasino", + "short_name": "SimCasino", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#1a2c38", + "background_color": "#1a2c38", + "display": "standalone" +} diff --git a/apps/frontend/public/web-app-manifest-192x192.png b/apps/frontend/public/web-app-manifest-192x192.png new file mode 100644 index 0000000..6bb8550 Binary files /dev/null and b/apps/frontend/public/web-app-manifest-192x192.png differ diff --git a/apps/frontend/public/web-app-manifest-512x512.png b/apps/frontend/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..53ee9f0 Binary files /dev/null and b/apps/frontend/public/web-app-manifest-512x512.png differ diff --git a/apps/frontend/src/_public/login.tsx b/apps/frontend/src/_public/login.tsx new file mode 100644 index 0000000..2798544 --- /dev/null +++ b/apps/frontend/src/_public/login.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Login from '@/features/auth'; + +export const Route = createFileRoute('/_public/login')({ + component: Login, +}); diff --git a/apps/frontend/src/api/_utils/axiosInstance.ts b/apps/frontend/src/api/_utils/axiosInstance.ts new file mode 100644 index 0000000..fc25dee --- /dev/null +++ b/apps/frontend/src/api/_utils/axiosInstance.ts @@ -0,0 +1,27 @@ +import type { AxiosError } from 'axios'; +import axios from 'axios'; + +export const setupInterceptors = ({ + authErrCb, +}: { + authErrCb: () => void; +}): void => { + axios.interceptors.request.use(config => { + return { + ...config, + withCredentials: true, + }; + }); + + axios.interceptors.response.use( + response => { + return Promise.resolve(response); + }, + (error: AxiosError) => { + if (error.response?.status === 401) { + authErrCb(); + } + return Promise.reject(error); + } + ); +}; diff --git a/apps/frontend/src/api/_utils/fetch.ts b/apps/frontend/src/api/_utils/fetch.ts new file mode 100644 index 0000000..2befbcc --- /dev/null +++ b/apps/frontend/src/api/_utils/fetch.ts @@ -0,0 +1,19 @@ +import axios, { type AxiosRequestConfig } from 'axios'; +import { BASE_API_URL } from '@/const/routes'; + +export const fetchGet = async ( + url: string, + config?: AxiosRequestConfig +): Promise => { + const response = await axios.get(BASE_API_URL + url, config); + return response.data; +}; + +export const fetchPost = async ( + url: string, + data: T, + config?: AxiosRequestConfig +): Promise => { + const response = await axios.post(BASE_API_URL + url, data, config); + return response.data; +}; diff --git a/apps/frontend/src/api/auth.ts b/apps/frontend/src/api/auth.ts new file mode 100644 index 0000000..3c1d77b --- /dev/null +++ b/apps/frontend/src/api/auth.ts @@ -0,0 +1,8 @@ +import type { IUser, ApiResponse } from '@repo/common/types'; +import { fetchGet } from './_utils/fetch'; + +export const getUserDetails = async (): Promise> => { + return fetchGet('/api/v1/auth/me', { + withCredentials: true, + }); +}; diff --git a/apps/frontend/src/api/balance.ts b/apps/frontend/src/api/balance.ts new file mode 100644 index 0000000..0739e31 --- /dev/null +++ b/apps/frontend/src/api/balance.ts @@ -0,0 +1,16 @@ +import type { ApiResponse } from '@repo/common/types'; +import { fetchGet } from './_utils/fetch'; + +interface BalanceResponse { + balance: number; +} + +export const getBalance = async (): Promise => { + const { data } = await fetchGet>>( + '/api/v1/user/balance', + { + withCredentials: true, + } + ); + return data.balance; +}; diff --git a/apps/frontend/src/api/games/bets.ts b/apps/frontend/src/api/games/bets.ts new file mode 100644 index 0000000..b5891b6 --- /dev/null +++ b/apps/frontend/src/api/games/bets.ts @@ -0,0 +1,25 @@ +import type { + ApiResponse, + PaginatedBetData, + BetData, +} from '@repo/common/types'; +import { fetchGet } from '../_utils/fetch'; + +export const fetchAllBets = async (): Promise< + ApiResponse<{ bets: PaginatedBetData[] }> +> => fetchGet('/api/v1/games/bets'); + +export const fetchBetById = async ( + betId: number +): Promise> => { + if (!betId) { + return Promise.resolve({ + data: { bet: null }, + statusCode: 200, + error: null, + message: '', + success: true, + }); + } + return fetchGet(`/api/v1/games/bets/${betId}`, { withCredentials: true }); +}; diff --git a/apps/frontend/src/api/games/blackjack.ts b/apps/frontend/src/api/games/blackjack.ts new file mode 100644 index 0000000..cc4db61 --- /dev/null +++ b/apps/frontend/src/api/games/blackjack.ts @@ -0,0 +1,40 @@ +import type { + BlackjackActions, + BlackjackPlayRoundResponse, +} from '@repo/common/game-utils/blackjack/types.js'; +import type { ApiResponse } from '@repo/common/types'; +import { fetchGet, fetchPost } from '../_utils/fetch'; + +export const blackjackBet = async ({ + betAmount, +}: { + betAmount: number; +}): Promise> => { + return fetchPost( + '/api/v1/games/blackjack/bet', + { betAmount }, + { + withCredentials: true, + } + ); +}; + +export const getActiveGame = async (): Promise< + ApiResponse +> => { + return fetchGet('/api/v1/games/blackjack/active', { + withCredentials: true, + }); +}; + +export const playRound = async ( + action: BlackjackActions +): Promise> => { + return fetchPost( + '/api/v1/games/blackjack/next', + { action }, + { + withCredentials: true, + } + ); +}; diff --git a/apps/frontend/src/api/games/dice.ts b/apps/frontend/src/api/games/dice.ts new file mode 100644 index 0000000..fc90cee --- /dev/null +++ b/apps/frontend/src/api/games/dice.ts @@ -0,0 +1,18 @@ +import type { ApiResponse } from '@repo/common/types'; +import type { + DicePlaceBetRequestBody, + DicePlaceBetResponse, +} from '@repo/common/game-utils/dice/types.js'; +import { fetchPost } from '../_utils/fetch'; + +export const placeBet = async ( + data: DicePlaceBetRequestBody +): Promise> => { + return fetchPost>( + '/api/v1/games/dice/place-bet', + data, + { + withCredentials: true, + } + ); +}; diff --git a/apps/frontend/src/api/games/keno.ts b/apps/frontend/src/api/games/keno.ts new file mode 100644 index 0000000..e901938 --- /dev/null +++ b/apps/frontend/src/api/games/keno.ts @@ -0,0 +1,21 @@ +import type { ApiResponse } from '@repo/common/types'; +import type { KenoResponse } from '@repo/common/game-utils/keno/types.js'; +import { fetchPost } from '../_utils/fetch'; + +export const placeBet = async ({ + betAmount, + selectedTiles, + risk, +}: { + betAmount: number; + selectedTiles: number[]; + risk: string; +}): Promise> => { + return fetchPost( + '/api/v1/games/keno/place-bet', + { betAmount, selectedTiles, risk }, + { + withCredentials: true, + } + ); +}; diff --git a/apps/frontend/src/api/games/mines.ts b/apps/frontend/src/api/games/mines.ts new file mode 100644 index 0000000..da7c965 --- /dev/null +++ b/apps/frontend/src/api/games/mines.ts @@ -0,0 +1,54 @@ +import type { ApiResponse } from '@repo/common/types'; +import type { + MinesGameOverResponse, + MinesPlayRoundResponse, +} from '@repo/common/game-utils/mines/types.js'; +import { fetchGet, fetchPost } from '../_utils/fetch'; + +export const startGame = async ({ + betAmount, + minesCount, +}: { + betAmount: number; + minesCount: number; +}): Promise> => { + return fetchPost( + '/api/v1/games/mines/start', + { betAmount, minesCount }, + { + withCredentials: true, + } + ); +}; + +export const playRound = async ( + selectedTileIndex: number +): Promise> => { + return fetchPost( + '/api/v1/games/mines/play-round', + { selectedTileIndex }, + { + withCredentials: true, + } + ); +}; + +export const cashOut = async (): Promise< + ApiResponse +> => { + return fetchPost( + '/api/v1/games/mines/cash-out', + {}, + { + withCredentials: true, + } + ); +}; + +export const getActiveGame = async (): Promise< + ApiResponse +> => { + return fetchGet('/api/v1/games/mines/active', { + withCredentials: true, + }); +}; diff --git a/apps/frontend/src/api/games/roulette.ts b/apps/frontend/src/api/games/roulette.ts new file mode 100644 index 0000000..f0ec50d --- /dev/null +++ b/apps/frontend/src/api/games/roulette.ts @@ -0,0 +1,18 @@ +import type { ApiResponse } from '@repo/common/types'; +import type { + RouletteBet, + RoulettePlaceBetResponse, +} from '@repo/common/game-utils/roulette/index.js'; +import { fetchPost } from '../_utils/fetch'; + +export const placeBet = async ( + bets: RouletteBet[] +): Promise> => { + return fetchPost( + '/api/v1/games/roulette/place-bet', + { bets }, + { + withCredentials: true, + } + ); +}; diff --git a/apps/frontend/src/api/user.ts b/apps/frontend/src/api/user.ts new file mode 100644 index 0000000..8a02e9a --- /dev/null +++ b/apps/frontend/src/api/user.ts @@ -0,0 +1,46 @@ +import type { + ProvablyFairStateResponse, + ApiResponse, + PaginatedBetsResponse, +} from '@repo/common/types'; +import { fetchGet, fetchPost } from './_utils/fetch'; + +export const fetchActiveSeeds = async (): Promise< + ApiResponse +> => + fetchGet('/api/v1/user/provably-fair-state', { + withCredentials: true, + }); + +export const fetchRotateSeedPair = async ( + clientSeed: string +): Promise> => + fetchPost( + '/api/v1/user/rotate-seeds', + { clientSeed }, + { + withCredentials: true, + } + ); + +export const fetchRevealedServerSeed = async ( + hashedServerSeed: string +): Promise> => + fetchGet(`/api/v1/user/unhash-server-seed/${hashedServerSeed}`, { + withCredentials: true, + }); + +export const fetchUserBetHistory = async ({ + page = 1, + pageSize = 10, +}: { + page?: number; + pageSize?: number; +}): Promise> => + fetchGet('/api/v1/user/bets', { + withCredentials: true, + params: { + page, + pageSize, + }, + }); diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx new file mode 100644 index 0000000..aa13d90 --- /dev/null +++ b/apps/frontend/src/app.tsx @@ -0,0 +1,34 @@ +import './index.css'; +import { createRouter, RouterProvider } from '@tanstack/react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { routeTree } from './routeTree.gen'; +import type { AuthState } from './features/auth/store/authStore'; +import { useAuthStore } from './features/auth/store/authStore'; + +const queryClient = new QueryClient(); + +const router = createRouter({ + routeTree, + defaultPreload: false, + context: { + authStore: undefined, + } as { authStore: AuthState | undefined }, +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +function App(): JSX.Element { + const authStore = useAuthStore(); + + return ( + + + + ); +} + +export default App; diff --git a/apps/frontend/src/assets/audio/bet.mp3 b/apps/frontend/src/assets/audio/bet.mp3 new file mode 100644 index 0000000..b3b7ca3 Binary files /dev/null and b/apps/frontend/src/assets/audio/bet.mp3 differ diff --git a/apps/frontend/src/assets/audio/rolling.mp3 b/apps/frontend/src/assets/audio/rolling.mp3 new file mode 100644 index 0000000..74035f0 Binary files /dev/null and b/apps/frontend/src/assets/audio/rolling.mp3 differ diff --git a/apps/frontend/src/assets/audio/tick.mp3 b/apps/frontend/src/assets/audio/tick.mp3 new file mode 100644 index 0000000..c04e37a Binary files /dev/null and b/apps/frontend/src/assets/audio/tick.mp3 differ diff --git a/apps/frontend/src/assets/audio/win.mp3 b/apps/frontend/src/assets/audio/win.mp3 new file mode 100644 index 0000000..54313ae Binary files /dev/null and b/apps/frontend/src/assets/audio/win.mp3 differ diff --git a/apps/frontend/src/assets/game-icons/roulette/chip-bg-img.svg b/apps/frontend/src/assets/game-icons/roulette/chip-bg-img.svg new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/assets/game-icons/roulette/roulette-icon.svg b/apps/frontend/src/assets/game-icons/roulette/roulette-icon.svg new file mode 100644 index 0000000..21bc523 --- /dev/null +++ b/apps/frontend/src/assets/game-icons/roulette/roulette-icon.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/frontend/src/assets/icons/bet.tsx b/apps/frontend/src/assets/icons/bet.tsx new file mode 100644 index 0000000..12f08a5 --- /dev/null +++ b/apps/frontend/src/assets/icons/bet.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils'; + +const BetIcon = ({ className }: { className?: string }) => { + return ( + + + + + ); +}; + +export default BetIcon; diff --git a/apps/frontend/src/assets/icons/bets.tsx b/apps/frontend/src/assets/icons/bets.tsx new file mode 100644 index 0000000..7efc556 --- /dev/null +++ b/apps/frontend/src/assets/icons/bets.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +const BetsIcon = ({ className }: { className?: string }) => { + return ( + + + + + ); +}; + +export default BetsIcon; diff --git a/apps/frontend/src/assets/icons/copy.tsx b/apps/frontend/src/assets/icons/copy.tsx new file mode 100644 index 0000000..98328cb --- /dev/null +++ b/apps/frontend/src/assets/icons/copy.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const CopyIcon = ({ className }: { className?: string }) => { + return ( + + + + ); +}; + +export default CopyIcon; diff --git a/apps/frontend/src/assets/icons/logout.tsx b/apps/frontend/src/assets/icons/logout.tsx new file mode 100644 index 0000000..b2736a5 --- /dev/null +++ b/apps/frontend/src/assets/icons/logout.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const LogoutIcon = ({ className }: { className?: string }) => { + return ( + + + + + ); +}; + +export default LogoutIcon; diff --git a/apps/frontend/src/assets/icons/user.tsx b/apps/frontend/src/assets/icons/user.tsx new file mode 100644 index 0000000..f3cc54d --- /dev/null +++ b/apps/frontend/src/assets/icons/user.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const UserIcon = () => { + return ( + + {' '} + {' '} + + + ); +}; + +export default UserIcon; diff --git a/apps/frontend/src/common/forms/components/InputWithIcon.tsx b/apps/frontend/src/common/forms/components/InputWithIcon.tsx new file mode 100644 index 0000000..0a628b6 --- /dev/null +++ b/apps/frontend/src/common/forms/components/InputWithIcon.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; +import type { InputProps } from '@/components/ui/input'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +interface InputWithIconProps extends InputProps { + icon: ReactNode; + wrapperClassName?: string; + leftIcon?: ReactNode; +} + +function InputWithIcon({ + icon, + leftIcon = null, + wrapperClassName, + ...inputProps +}: InputWithIconProps): JSX.Element { + return ( +
+ {leftIcon} + + {icon} +
+ ); +} + +export default InputWithIcon; diff --git a/apps/frontend/src/common/hooks/useAudio.ts b/apps/frontend/src/common/hooks/useAudio.ts new file mode 100644 index 0000000..20d2a57 --- /dev/null +++ b/apps/frontend/src/common/hooks/useAudio.ts @@ -0,0 +1,108 @@ +import { useCallback, useRef, useState } from 'react'; +import throttle from 'lodash/throttle'; + +const useAudio = ( + audioSrc: string, + volume = 1 +): { + isPlaying: boolean; + play: () => Promise; + playThrottled: () => void; + playInfinite: () => void; + pause: () => void; + stop: () => void; + setVolume: (volume: number) => void; + setCurrentTime: (time: number) => void; +} => { + const [isPlaying, setIsPlaying] = useState(false); + const audioRef = useRef(new Audio(audioSrc)); + + const playThrottledRef = useRef( + throttle(() => { + const audio = new Audio(audioSrc); + audio.volume = volume; + audio + .play() + .then(() => { + setIsPlaying(true); + audio.onended = () => { + setIsPlaying(false); + }; + }) + .catch((error: Error) => { + return error; + }); + }, 2) + ); + + const play = async (): Promise => { + return new Promise((resolve, reject) => { + const audio = new Audio(audioSrc); + audio.volume = volume; + + audio + .play() + .then(() => { + setIsPlaying(true); + audio.onended = () => { + setIsPlaying(false); + resolve(); + }; + }) + .catch((error: Error) => { + reject(error); + }); + }); + }; + + const playInfinite = useCallback((): void => { + const audio = audioRef.current; + audio.loop = true; + audio.volume = volume; + audio + .play() + .then(() => { + setIsPlaying(true); + }) + .catch((error: Error) => { + return error; + }); + }, [volume]); + + const playThrottled = (): void => { + playThrottledRef.current(); + }; + + const pause = (): void => { + audioRef.current.pause(); + setIsPlaying(false); + }; + + const stop = useCallback((): void => { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + audioRef.current.loop = false; + setIsPlaying(false); + }, []); + + const setVolume = (newVolume: number): void => { + audioRef.current.volume = newVolume; + }; + + const setCurrentTime = (time: number): void => { + audioRef.current.currentTime = time; + }; + + return { + isPlaying, + play, + playThrottled, + playInfinite, + pause, + stop, + setVolume, + setCurrentTime, + }; +}; + +export { useAudio }; diff --git a/apps/frontend/src/common/hooks/useViewportType.ts b/apps/frontend/src/common/hooks/useViewportType.ts new file mode 100644 index 0000000..d43cb72 --- /dev/null +++ b/apps/frontend/src/common/hooks/useViewportType.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +export enum ViewportType { + Mobile = 'mobile', + Tablet = 'tablet', + Desktop = 'desktop', +} + +export const VIEWPORT_MOBILE_MAX = 767; +export const VIEWPORT_TABLET_MAX = 1023; + +function getViewportType(width: number): ViewportType { + if (width <= VIEWPORT_MOBILE_MAX) return ViewportType.Mobile; + if (width <= VIEWPORT_TABLET_MAX) return ViewportType.Tablet; + return ViewportType.Desktop; +} + +export function useViewportType(): ViewportType { + const [viewport, setViewport] = useState(() => + typeof window !== 'undefined' + ? getViewportType(window.innerWidth) + : ViewportType.Desktop + ); + + useEffect(() => { + function handleResize() { + setViewport(getViewportType(window.innerWidth)); + } + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return viewport; +} diff --git a/apps/frontend/src/common/icons/dice.jsx b/apps/frontend/src/common/icons/dice.jsx new file mode 100644 index 0000000..0755129 --- /dev/null +++ b/apps/frontend/src/common/icons/dice.jsx @@ -0,0 +1,32 @@ +function Dice() { + return ( + + + + + 2Artboard 440 + + + + + + + ); +} + +export default Dice; diff --git a/apps/frontend/src/components/Header/Balance.tsx b/apps/frontend/src/components/Header/Balance.tsx new file mode 100644 index 0000000..1d89788 --- /dev/null +++ b/apps/frontend/src/components/Header/Balance.tsx @@ -0,0 +1,69 @@ +import { useQuery } from '@tanstack/react-query'; +import { BadgeDollarSign } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { getBalance } from '@/api/balance'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; + +function useAnimatedValue(targetValue: number): string { + const [displayValue, setDisplayValue] = useState(targetValue); + const animationRef = useRef(); + + useEffect(() => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + + const startValue = displayValue; + const endValue = targetValue; + const duration = 200; // Fast 300ms animation + const startTime = performance.now(); + + const animate = (currentTime: number): void => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Easing function for smooth animation + const easeOut = 1 - Math.pow(1 - progress, 3); + + const currentValue = startValue + (endValue - startValue) * easeOut; + setDisplayValue(currentValue); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate); + } + }; + + if (startValue !== endValue) { + animationRef.current = requestAnimationFrame(animate); + } + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [targetValue, displayValue]); + + return displayValue.toFixed(2); +} + +export function Balance(): JSX.Element { + const { data: balance } = useQuery({ + queryKey: ['balance'], + queryFn: getBalance, + refetchInterval: 120000, + // Refetch every 2 minutes + }); + + const animatedBalance = useAnimatedValue(balance ?? 0); + + return ( + } + value={animatedBalance} + wrapperClassName="shadow-md w-32 sm:w-48 md:w-60" + /> + ); +} diff --git a/apps/frontend/src/components/Header/index.tsx b/apps/frontend/src/components/Header/index.tsx new file mode 100644 index 0000000..aad3156 --- /dev/null +++ b/apps/frontend/src/components/Header/index.tsx @@ -0,0 +1,129 @@ +import { Link, useNavigate } from '@tanstack/react-router'; +import { Balance } from './Balance'; +import { getAuthState } from '@/features/auth/store/authStore'; +import { Button } from '../ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import BetsIcon from '@/assets/icons/bets'; +import LogoutIcon from '@/assets/icons/logout'; +import UserIcon from '@/assets/icons/user'; +import { cn } from '@/lib/utils'; +import { fetchGet } from '@/api/_utils/fetch'; + +enum SettingsDropdownItems { + MY_BETS = 'my-bets', + // SETTINGS = 'settings', + LOGOUT = 'logout', +} + +const settingsDropdownItems: Record< + SettingsDropdownItems, + { label: string; icon: React.ElementType } +> = { + // [SettingsDropdownItems.SETTINGS]: { + // label: 'Settings', + // icon: Settings, + // path: '/settings', + // }, + [SettingsDropdownItems.MY_BETS]: { + label: 'My Bets', + icon: BetsIcon, + }, + [SettingsDropdownItems.LOGOUT]: { + label: 'Logout', + icon: LogoutIcon, + }, +}; + +const settingsDropdownItemsOrder = [ + SettingsDropdownItems.MY_BETS, + SettingsDropdownItems.LOGOUT, +]; + +export function Header({ + openLoginModal = true, +}: { + openLoginModal?: boolean; +}): JSX.Element { + const { user, showLoginModal, setUser } = getAuthState(); + const navigate = useNavigate(); + + const handleDropdownItemClick = async (item: SettingsDropdownItems) => { + switch (item) { + case SettingsDropdownItems.MY_BETS: + navigate({ to: '/my-bets' }); + break; + case SettingsDropdownItems.LOGOUT: + try { + await fetchGet('/api/v1/auth/logout', { withCredentials: true }); + } catch (error) { + console.error('Logout error:', error); + } finally { + // Clear user state regardless of API response + setUser(null); + // Navigate to home page instead of reload for better UX + navigate({ to: '/' }); + } + break; + } + }; + + return ( +
+
+ + SimCasino Logo + SimCasino Mini Logo + + {user ? : null} + {user ? ( + + + + + + {settingsDropdownItemsOrder.map(item => { + const { label, icon: Icon } = settingsDropdownItems[item]; + return ( + handleDropdownItemClick(item)} + > + + {label} + + ); + })} + + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/frontend/src/components/documentation/bullet.tsx b/apps/frontend/src/components/documentation/bullet.tsx new file mode 100644 index 0000000..095088a --- /dev/null +++ b/apps/frontend/src/components/documentation/bullet.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const BulletPoints = ({ + bulletPoints, +}: { + bulletPoints: React.ReactNode[]; +}) => { + return ( +
    + {bulletPoints.map((point, index) => ( +
  • {point}
  • + ))} +
+ ); +}; + +export default BulletPoints; diff --git a/apps/frontend/src/components/documentation/code.tsx b/apps/frontend/src/components/documentation/code.tsx new file mode 100644 index 0000000..3eba6d9 --- /dev/null +++ b/apps/frontend/src/components/documentation/code.tsx @@ -0,0 +1,22 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const Code = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( + + {children} + + ); +}; + +export default Code; diff --git a/apps/frontend/src/components/documentation/heading.tsx b/apps/frontend/src/components/documentation/heading.tsx new file mode 100644 index 0000000..ef22a76 --- /dev/null +++ b/apps/frontend/src/components/documentation/heading.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const Heading = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
{children}
+ ); +}; + +export default Heading; diff --git a/apps/frontend/src/components/documentation/index.tsx b/apps/frontend/src/components/documentation/index.tsx new file mode 100644 index 0000000..29beee1 --- /dev/null +++ b/apps/frontend/src/components/documentation/index.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Documentation = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +export default Documentation; diff --git a/apps/frontend/src/components/documentation/link.tsx b/apps/frontend/src/components/documentation/link.tsx new file mode 100644 index 0000000..5b2d07f --- /dev/null +++ b/apps/frontend/src/components/documentation/link.tsx @@ -0,0 +1,29 @@ +import { cn } from '@/lib/utils'; +import { ExternalLink } from 'lucide-react'; +import React from 'react'; + +const Link = ({ + link, + text, + className, +}: { + link: string; + text: string; + className?: string; +}) => { + return ( + + + {text} + + + + ); +}; + +export default Link; diff --git a/apps/frontend/src/components/documentation/paragraph.tsx b/apps/frontend/src/components/documentation/paragraph.tsx new file mode 100644 index 0000000..f4f10b3 --- /dev/null +++ b/apps/frontend/src/components/documentation/paragraph.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const Paragraph = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return

{children}

; +}; + +export default Paragraph; diff --git a/apps/frontend/src/components/documentation/section.tsx b/apps/frontend/src/components/documentation/section.tsx new file mode 100644 index 0000000..10310f3 --- /dev/null +++ b/apps/frontend/src/components/documentation/section.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Section = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +export default Section; diff --git a/apps/frontend/src/components/ui/accordion.tsx b/apps/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..be56d71 --- /dev/null +++ b/apps/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { cn } from '@/lib/utils'; +import { ChevronLeftIcon } from '@radix-ui/react-icons'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:-rotate-90', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/frontend/src/components/ui/avatar.tsx b/apps/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..4551323 --- /dev/null +++ b/apps/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/frontend/src/components/ui/badge.tsx b/apps/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..aa857b2 --- /dev/null +++ b/apps/frontend/src/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ + className, + variant, + ...props +}: BadgeProps): React.ReactElement { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..ee7ffa4 --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + hero: 'bg-gradient-primary text-foreground font-semibold shadow-glow hover:shadow-[0_0_40px_hsl(220_100%_60%_/_0.4)] hover:scale-[1.02] transition-all duration-300', + glow: 'bg-card border border-primary/20 text-card-foreground hover:border-primary/40 hover:bg-card/80 hover:shadow-glow transition-glow', + neon: 'bg-transparent border-2 border-accent-purple text-accent-foreground hover:bg-accent-purple/10 hover:shadow-[0_0_20px_hsl(270_100%_65%_/_0.3)] transition-all duration-300', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + xl: 'h-14 rounded-lg px-10 text-lg', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/card.tsx b/apps/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..22549bf --- /dev/null +++ b/apps/frontend/src/components/ui/card.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => ( +

+ {children} +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/apps/frontend/src/components/ui/carousel.tsx b/apps/frontend/src/components/ui/carousel.tsx new file mode 100644 index 0000000..a496b4b --- /dev/null +++ b/apps/frontend/src/components/ui/carousel.tsx @@ -0,0 +1,258 @@ +import * as React from 'react'; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from 'embla-carousel-react'; +import { ArrowLeftIcon, ArrowRightIcon } from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +interface CarouselProps { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +} + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +export function useCarousel(): CarouselContextProps { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error('useCarousel must be used within a '); + } + + return context; +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = 'horizontal', + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === 'horizontal' ? 'x' : 'y', + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((carouselApi: CarouselApi) => { + if (!carouselApi) { + return; + } + + setCanScrollPrev(carouselApi.canScrollPrev()); + setCanScrollNext(carouselApi.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) { + return; + } + + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) { + return; + } + + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + + return () => { + api.off('select', onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); + } +); +Carousel.displayName = 'Carousel'; + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +}); +CarouselContent.displayName = 'CarouselContent'; + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( +
+ ); +}); +CarouselItem.displayName = 'CarouselItem'; + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +}); +CarouselPrevious.displayName = 'CarouselPrevious'; + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +}); +CarouselNext.displayName = 'CarouselNext'; + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/apps/frontend/src/components/ui/common-data-table.tsx b/apps/frontend/src/components/ui/common-data-table.tsx new file mode 100644 index 0000000..369421b --- /dev/null +++ b/apps/frontend/src/components/ui/common-data-table.tsx @@ -0,0 +1,186 @@ +import type { + ColumnDef, + PaginationState, + Updater, +} from '@tanstack/react-table'; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + Table as TableUI, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './table'; +import { Button } from './button'; +import { EmptyState } from './empty-state'; + +interface CommonDataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount?: number; + setPagination?: (updater: Updater) => void; + pagination?: PaginationState; + rowCount?: number; + emptyState?: { + icon?: React.ReactNode; + title: string; + description?: string; + action?: { + label: string; + onClick: () => void; + }; + }; +} + +// Type declaration to extend ColumnDef with our custom meta properties +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + alignment?: 'left' | 'right' | 'center'; + } +} + +export function CommonDataTable({ + columns, + data, + pageCount, + setPagination, + pagination, + rowCount, + emptyState, +}: CommonDataTableProps): JSX.Element { + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount, + rowCount, + defaultColumn: { + size: 200, //starting column size + }, + columnResizeMode: 'onChange', + onPaginationChange: updater => { + if (!pagination) return; + // Handle the updater whether it's a function or an object + if (typeof updater === 'function') { + const newState = updater(pagination); + setPagination?.({ + pageIndex: newState.pageIndex, + pageSize: newState.pageSize, + }); + } else { + setPagination?.({ + pageIndex: updater.pageIndex, + pageSize: updater.pageSize, + }); + } + }, + state: { + pagination, + }, + }); + + if (table.getRowModel().rows.length === 0) { + return ( + + ); + } + + return ( +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + + + {/* Pagination controls */} + {pagination && (table.getCanPreviousPage() || table.getCanNextPage()) ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/components/ui/common-select.tsx b/apps/frontend/src/components/ui/common-select.tsx new file mode 100644 index 0000000..7522663 --- /dev/null +++ b/apps/frontend/src/components/ui/common-select.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './select'; +import { Label } from './label'; + +interface CommonSelectProps { + label: ReactNode; + options: { + label: ReactNode; + value: string; + }[]; + onValueChange: (value: string) => void; + value: string | null; + triggerClassName?: string; + labelClassName?: string; +} + +function CommonSelect({ + label, + options, + onValueChange, + value, + triggerClassName, + labelClassName, +}: CommonSelectProps): JSX.Element { + return ( +
+ + +
+ ); +} + +export default CommonSelect; diff --git a/apps/frontend/src/components/ui/common-tooltip/index.tsx b/apps/frontend/src/components/ui/common-tooltip/index.tsx new file mode 100644 index 0000000..6249bca --- /dev/null +++ b/apps/frontend/src/components/ui/common-tooltip/index.tsx @@ -0,0 +1,55 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +interface CommonTooltipProps { + children: React.ReactNode; + content: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + forceHide?: boolean; + contentClassName?: string; + arrowClassName?: string; +} + +function CommonTooltip({ + children, + content, + open, + onOpenChange = () => { + void 0; + }, + forceHide = false, + contentClassName, + arrowClassName, +}: CommonTooltipProps): JSX.Element { + if (forceHide) { + return <>{children}; + } + return ( + + + {children} + + {content} + + + + ); +} + +export default CommonTooltip; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c4d7202 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseIcon?: boolean; + } +>(({ className, children, showCloseIcon = true, ...props }, ref) => ( + + + + {children} + {showCloseIcon && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ + className, + ...props +}: React.HTMLAttributes): JSX.Element { + return ( +
+ ); +} +DialogHeader.displayName = 'DialogHeader'; + +function DialogFooter({ + className, + ...props +}: React.HTMLAttributes): JSX.Element { + return ( +
+ ); +} +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/dice-result-slider.tsx b/apps/frontend/src/components/ui/dice-result-slider.tsx new file mode 100644 index 0000000..97d9d42 --- /dev/null +++ b/apps/frontend/src/components/ui/dice-result-slider.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; +import { cn } from '@/lib/utils'; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + success?: boolean; + } +>(({ className, success, ...props }, ref) => { + return ( + props.value && ( + + +
+ Result Dice +
+ {props.value[0]} +
+
+
+
+ ) + ); +}); + +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/apps/frontend/src/components/ui/dice-slider.tsx b/apps/frontend/src/components/ui/dice-slider.tsx new file mode 100644 index 0000000..de086e9 --- /dev/null +++ b/apps/frontend/src/components/ui/dice-slider.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; +import { Tally3 } from 'lucide-react'; +import type { DiceCondition } from '@repo/common/game-utils/dice/types.js'; +import { cn } from '@/lib/utils'; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + condition: DiceCondition; + } +>(({ className, condition, ...props }, ref) => { + return ( + + + + + +
+ +
+
+
+ ); +}); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/apps/frontend/src/components/ui/dropdown-menu.tsx b/apps/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..77bdb11 --- /dev/null +++ b/apps/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,202 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +function DropdownMenuShortcut({ + className, + ...props +}: React.HTMLAttributes): JSX.Element { + return ( + + ); +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/frontend/src/components/ui/empty-state.tsx b/apps/frontend/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..30a477b --- /dev/null +++ b/apps/frontend/src/components/ui/empty-state.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from './button'; + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + action?: { + label: string; + onClick: () => void; + }; + className?: string; +} + +export function EmptyState({ + icon, + title, + description, + action, + className, +}: EmptyStateProps): JSX.Element { + return ( +
+ {icon && ( +
+ {React.cloneElement(icon as React.ReactElement, { + className: cn( + 'size-12 md:size-16', + (icon as React.ReactElement).props?.className + ), + })} +
+ )} + +

{title}

+ + {description && ( +

+ {description} +

+ )} + + {action && ( + + )} +
+ ); +} diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..ab9d582 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/frontend/src/components/ui/label.tsx b/apps/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..b6472ff --- /dev/null +++ b/apps/frontend/src/components/ui/label.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const labelVariants = cva( + 'text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-[#b1bad3]' +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/frontend/src/components/ui/playing-card.tsx b/apps/frontend/src/components/ui/playing-card.tsx new file mode 100644 index 0000000..96dc3c1 --- /dev/null +++ b/apps/frontend/src/components/ui/playing-card.tsx @@ -0,0 +1,128 @@ +import { CardSuits } from '@repo/common/game-utils/cards/types.js'; +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; +import { ViewportType } from '@/common/hooks/useViewportType'; +import { CARD_WIDTH } from '@/features/games/blackjack/utils/widthUtils'; + +export enum CardBorders { + SUCCESS = 'success', + ERROR = 'error', + WARNING = 'warning', + TRANSPARENT = 'transparent', + INFO = 'info', +} + +function PlayingCard({ + border = CardBorders.TRANSPARENT, + cardClassName, + className, + faceDown = false, + layoutId, + rank, + rankClassName, + suit, + suitClassName, + suitAndRankClassName, +}: { + border?: CardBorders; + cardClassName?: string; + className?: string; + faceDown?: boolean; + layoutId?: string; + rank?: string; + rankClassName?: string; + suit?: CardSuits; + suitClassName?: string; + suitAndRankClassName?: string; +}): JSX.Element { + return ( + + +
+ {rank && suit ? ( +
+ + {rank} + + {`${suit} +
+ ) : null} +
+ + {/* Card Back Face */} +
+ Card back +
+
+
+ ); +} + +export default PlayingCard; diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..bce20a5 --- /dev/null +++ b/apps/frontend/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + ref={ref} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + {/* */} + + {children} + + {/* */} + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/frontend/src/components/ui/separator.tsx b/apps/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..b56a73c --- /dev/null +++ b/apps/frontend/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref + ) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/frontend/src/components/ui/slider.tsx b/apps/frontend/src/components/ui/slider.tsx new file mode 100644 index 0000000..5c954b5 --- /dev/null +++ b/apps/frontend/src/components/ui/slider.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; +import { cn } from '@/lib/utils'; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/apps/frontend/src/components/ui/table.tsx b/apps/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..dddfdcb --- /dev/null +++ b/apps/frontend/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', + className + )} + ref={ref} + {...props} + /> +)); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]', + className + )} + ref={ref} + {...props} + /> +)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]', + className + )} + ref={ref} + {...props} + /> +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = 'TableCaption'; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/apps/frontend/src/components/ui/tabs.tsx b/apps/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..d6e2784 --- /dev/null +++ b/apps/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/frontend/src/components/ui/tooltip.tsx b/apps/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..7609ce0 --- /dev/null +++ b/apps/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { cn } from '@/lib/utils'; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + arrowClassName?: string; + } +>(({ arrowClassName = '', className, sideOffset = 4, ...props }, ref) => ( + + + {props.children} + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/frontend/src/const/games.ts b/apps/frontend/src/const/games.ts new file mode 100644 index 0000000..e7c85e3 --- /dev/null +++ b/apps/frontend/src/const/games.ts @@ -0,0 +1,67 @@ +import { DicesIcon, ShipWheelIcon } from 'lucide-react'; +import { z } from 'zod'; + +export enum Games { + DICE = 'dice', + // ROULETTE = 'roulette', + MINES = 'mines', + KENO = 'keno', + BLACKJACK = 'blackjack', +} + +export const gameSchema = z.enum([ + Games.DICE, + // Games.ROULETTE, + Games.MINES, + Games.KENO, + Games.BLACKJACK, +]); + +export type Game = (typeof Games)[keyof typeof Games]; + +export const GAME_VALUES_MAPPING = { + [Games.DICE]: { label: 'Dice', icon: DicesIcon, path: '/casino/games/dice' }, + // [Games.ROULETTE]: { + // label: 'Roulette', + // icon: ShipWheelIcon, + // path: '/casino/games/roulette', + // }, + [Games.MINES]: { + label: 'Mines', + // icon: DicesIcon, + path: '/casino/games/mines', + }, + [Games.KENO]: { + label: 'Keno', + // icon: DicesIcon, + path: '/casino/games/keno', + }, + [Games.BLACKJACK]: { + label: 'Blackjack', + // icon: DicesIcon, + path: '/casino/games/blackjack', + }, +}; + +export const GAMES_DROPDOWN_OPTIONS = [ + { + label: GAME_VALUES_MAPPING[Games.DICE].label, + value: Games.DICE, + }, + // { + // label: GAME_VALUES_MAPPING[Games.ROULETTE].label, + // value: Games.ROULETTE, + // }, + { + label: GAME_VALUES_MAPPING[Games.MINES].label, + value: Games.MINES, + }, + { + label: GAME_VALUES_MAPPING[Games.KENO].label, + value: Games.KENO, + }, + { + label: GAME_VALUES_MAPPING[Games.BLACKJACK].label, + value: Games.BLACKJACK, + }, +]; diff --git a/apps/frontend/src/const/routes.ts b/apps/frontend/src/const/routes.ts new file mode 100644 index 0000000..79127dd --- /dev/null +++ b/apps/frontend/src/const/routes.ts @@ -0,0 +1,3 @@ +export const BASE_API_URL = + (import.meta.env.VITE_APP_API_URL as string | undefined) || + 'http://localhost:5000'; diff --git a/apps/frontend/src/const/tables.ts b/apps/frontend/src/const/tables.ts new file mode 100644 index 0000000..72a9a1a --- /dev/null +++ b/apps/frontend/src/const/tables.ts @@ -0,0 +1,21 @@ +import { ViewportType } from '@/common/hooks/useViewportType'; + +export enum BetsTableColumns { + BET_ID = 'betId', + GAME = 'game', + DATE = 'date', + BET_AMOUNT = 'betAmount', + PAYOUT = 'payout', + PAYOUT_MULTIPLIER = 'payoutMultiplier', + USER = 'user', +} + +export const betsTableViewportWiseColumns = { + [ViewportType.Mobile]: [BetsTableColumns.GAME, BetsTableColumns.PAYOUT], + [ViewportType.Tablet]: [ + BetsTableColumns.GAME, + BetsTableColumns.PAYOUT, + BetsTableColumns.PAYOUT_MULTIPLIER, + ], + [ViewportType.Desktop]: null, +}; diff --git a/apps/frontend/src/features/all-bets/all-bets-table.tsx b/apps/frontend/src/features/all-bets/all-bets-table.tsx new file mode 100644 index 0000000..ee08941 --- /dev/null +++ b/apps/frontend/src/features/all-bets/all-bets-table.tsx @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; +import { CommonDataTable } from '@/components/ui/common-data-table'; +import { fetchAllBets } from '@/api/games/bets'; +import { columns } from './columns'; +import { BetsTableColumns, betsTableViewportWiseColumns } from '@/const/tables'; +import { useViewportType } from '@/common/hooks/useViewportType'; +import BetsIcon from '@/assets/icons/bets'; + +function AllBetsTable(): JSX.Element { + const { data } = useQuery({ + queryKey: ['all-bets'], + queryFn: () => fetchAllBets(), + placeholderData: prev => prev, + }); + const viewport = useViewportType(); + const usedColumns = betsTableViewportWiseColumns[viewport] + ? columns.filter(col => + betsTableViewportWiseColumns[viewport]?.includes( + col.id as BetsTableColumns + ) + ) + : columns; + return ( + , + title: 'No bets found', + description: + 'There are no bets to display at the moment. Check back later to see betting activity.', + }} + /> + ); +} + +export default AllBetsTable; diff --git a/apps/frontend/src/features/all-bets/columns.tsx b/apps/frontend/src/features/all-bets/columns.tsx new file mode 100644 index 0000000..5947360 --- /dev/null +++ b/apps/frontend/src/features/all-bets/columns.tsx @@ -0,0 +1,124 @@ +import type { PaginatedBetData } from '@repo/common/types'; +import type { ColumnDef } from '@tanstack/react-table'; +import { BadgeDollarSign } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; +import { format, isValid } from 'date-fns'; +import { GAME_VALUES_MAPPING } from '@/const/games'; +import { cn } from '@/lib/utils'; +import { BetsTableColumns } from '@/const/tables'; +import { GLOBAL_MODAL } from '../global-modals/types'; + +export const columns: ColumnDef[] = [ + { + header: 'Game', + accessorKey: BetsTableColumns.GAME, + id: BetsTableColumns.GAME, + cell: ({ row }) => { + const game = + GAME_VALUES_MAPPING[ + row.original.game as keyof typeof GAME_VALUES_MAPPING + ]; + + return ( + +
+ {'icon' in game && ( + + )} + {game.label} +
+ + ); + }, + }, + { + header: 'User', + accessorKey: BetsTableColumns.USER, + id: BetsTableColumns.USER, + cell: ({ row }) => { + const user = row.original.user; + return ( +
{user ? user.name : 'Unknown'}
+ ); + }, + }, + { + header: 'Date', + accessorKey: BetsTableColumns.DATE, + id: BetsTableColumns.DATE, + cell: ({ row }) => { + // Format the date using date-fns + const date = new Date(row.original.date); + const formattedDate = isValid(date) + ? format(date, 'h:mm a M/d/yyyy') + : String(row.original.date); + + return ( +
{formattedDate}
+ ); + }, + meta: { + alignment: 'right', + }, + }, + { + header: 'Bet Amount', + accessorKey: BetsTableColumns.BET_AMOUNT, + id: BetsTableColumns.BET_AMOUNT, + cell: ({ row }) => { + return ( +
+ {row.original.betAmount.toFixed(2)}{' '} + +
+ ); + }, + meta: { + alignment: 'right', + }, + }, + { + header: 'Multiplier', + accessorKey: BetsTableColumns.PAYOUT_MULTIPLIER, + id: BetsTableColumns.PAYOUT_MULTIPLIER, + cell: ({ row }) => { + return ( +
+ {row.original.payoutMultiplier.toFixed(2)}x +
+ ); + }, + meta: { + alignment: 'right', + }, + }, + { + header: 'Payout', + accessorKey: BetsTableColumns.PAYOUT, + id: BetsTableColumns.PAYOUT, + cell: ({ row }) => { + return ( +
0, + } + )} + > + {row.original.payout.toFixed(2)}{' '} + +
+ ); + }, + meta: { + alignment: 'right', + }, + }, +]; diff --git a/apps/frontend/src/features/auth/components/LoginModal.tsx b/apps/frontend/src/features/auth/components/LoginModal.tsx new file mode 100644 index 0000000..6cf5c17 --- /dev/null +++ b/apps/frontend/src/features/auth/components/LoginModal.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; +import { SiGoogle } from '@icons-pack/react-simple-icons'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useAuthStore } from '../store/authStore'; +import { LOGIN_URL } from '../const/immutableConst'; + +export function LoginModal(): JSX.Element { + const { user, isModalOpen, hideLoginModal } = useAuthStore(); + + const handleGoogleLogin = (): void => { + // Save current URL to redirect back after login + const currentUrl = window.location.href; + + // Redirect to Google OAuth endpoint + window.location.href = `${LOGIN_URL}?redirect_to=${encodeURIComponent(currentUrl)}`; + }; + + // Close the modal when user becomes authenticated + useEffect(() => { + if (user) { + hideLoginModal(); + } + }, [user, hideLoginModal]); + + return ( + + + + + Welcome to SimCasino + + + +
+ + +
+
+ +
+
+ +

+ By continuing, you agree to our{' '} + {' '} + and{' '} + +

+
+
+
+ ); +} diff --git a/apps/frontend/src/features/auth/const/immutableConst.ts b/apps/frontend/src/features/auth/const/immutableConst.ts new file mode 100644 index 0000000..c2981de --- /dev/null +++ b/apps/frontend/src/features/auth/const/immutableConst.ts @@ -0,0 +1,5 @@ +import { BASE_API_URL } from '@/const/routes'; + +export const key = 'user'; + +export const LOGIN_URL = `${BASE_API_URL}/api/v1/auth/google`; diff --git a/apps/frontend/src/features/auth/store/authStore.ts b/apps/frontend/src/features/auth/store/authStore.ts new file mode 100644 index 0000000..8f6fb17 --- /dev/null +++ b/apps/frontend/src/features/auth/store/authStore.ts @@ -0,0 +1,37 @@ +import type { IUser } from '@repo/common/types'; +import { create } from 'zustand'; +import { getStoredUser, setStoredUser } from '../utils/storage'; + +export interface AuthState { + user: IUser | null; + isModalOpen: boolean; + setUser: (user: IUser | null) => void; + showLoginModal: () => void; + hideLoginModal: () => void; +} + +export const useAuthStore = create(set => ({ + user: getStoredUser(), + isModalOpen: false, + setUser: user => { + setStoredUser(user); + set({ user }); + }, + showLoginModal: () => { + set({ isModalOpen: true }); + }, + hideLoginModal: () => { + set({ isModalOpen: false }); + }, +})); + +export const getAuthState = (): AuthState => { + const state = useAuthStore.getState(); + return { + user: state.user, + isModalOpen: state.isModalOpen, + setUser: state.setUser, + showLoginModal: state.showLoginModal, + hideLoginModal: state.hideLoginModal, + }; +}; diff --git a/apps/frontend/src/features/auth/utils/storage.ts b/apps/frontend/src/features/auth/utils/storage.ts new file mode 100644 index 0000000..1ea7333 --- /dev/null +++ b/apps/frontend/src/features/auth/utils/storage.ts @@ -0,0 +1,15 @@ +import type { IUser } from '@repo/common/types'; +import { key } from '../const/immutableConst'; + +export const getStoredUser = (): IUser | null => { + const user = localStorage.getItem(key); + return user ? (JSON.parse(user) as IUser) : null; +}; + +export const setStoredUser = (user: IUser | null): void => { + if (user) { + localStorage.setItem(key, JSON.stringify(user)); + } else { + localStorage.removeItem(key); + } +}; diff --git a/apps/frontend/src/features/games/blackjack/BlackjackDemo.tsx b/apps/frontend/src/features/games/blackjack/BlackjackDemo.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/features/games/blackjack/components/BettingControls.tsx b/apps/frontend/src/features/games/blackjack/components/BettingControls.tsx new file mode 100644 index 0000000..330b836 --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/components/BettingControls.tsx @@ -0,0 +1,163 @@ +import React, { useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getValidActionsFromState } from '@repo/common/game-utils/blackjack/utils.js'; +import { BlackjackActions } from '@repo/common/game-utils/blackjack/types.js'; +import { Button } from '@/components/ui/button'; +import { blackjackBet, getActiveGame, playRound } from '@/api/games/blackjack'; +import useBlackjackStore from '../store/blackjackStore'; +import { BetAmountInput } from '../../common/components/BetAmountInput'; +import { BetButton } from '../../common/components/BettingControls'; + +const BlackjackActionButtons = [ + { + label: 'Hit', + value: BlackjackActions.HIT, + icon: '/games/blackjack/hit.svg', + }, + { + label: 'Stand', + value: BlackjackActions.STAND, + icon: '/games/blackjack/stand.svg', + }, + { + label: 'Split', + value: BlackjackActions.SPLIT, + icon: '/games/blackjack/split.svg', + }, + { + label: 'Double', + value: BlackjackActions.DOUBLE, + icon: '/games/blackjack/double.svg', + }, +]; + +function BettingControls(): JSX.Element { + const { + betAmount, + setBetAmount, + gameState, + setGameState, + playNextRoundHandler, + setActiveGame, + initializeGame, + } = useBlackjackStore(); + + const { isPending: isFetchingActiveGame, data: activeGame } = useQuery({ + queryKey: ['blackjack-active-game'], + queryFn: getActiveGame, + retry: false, + }); + + const queryClient = useQueryClient(); + + // Load active game on mount + useEffect(() => { + if (activeGame?.data && !gameState) { + setBetAmount(Number(activeGame.data.betAmount)); + setActiveGame(activeGame.data); + } + }, [activeGame, gameState, setGameState, setBetAmount, setActiveGame]); + + const { mutate: bet, isPending: isStartingGame } = useMutation({ + mutationKey: ['blackjack-bet'], + mutationFn: () => blackjackBet({ betAmount }), + onSuccess: async ({ data }) => { + setGameState(data, false); + setBetAmount(Number(data.betAmount)); + if (data.active) { + void initializeGame(data); + } else { + await initializeGame(data); + } + queryClient.setQueryData(['balance'], data.balance); + }, + }); + + const { mutate: playNextRound, isPending: isPlayingRound } = useMutation({ + mutationKey: ['blackjack-play-round'], + mutationFn: (action: BlackjackActions) => playRound(action), + onSuccess: async ({ data }) => { + setGameState(data, false); + setBetAmount(Number(data.betAmount)); + await playNextRoundHandler(data); + queryClient.setQueryData(['balance'], data.balance); + }, + }); + const balance = queryClient.getQueryData(['balance']); + const isDisabled = betAmount > (balance ?? 0) || betAmount <= 0; + + const validActions = getValidActionsFromState({ + state: gameState?.state || null, + active: gameState?.active || false, + }); + + return ( +
+
+ { + setBetAmount(amount * multiplier); + }} + /> +
+ {validActions.insurance ? ( + <> +
+ Insurance? +
+ + + + ) : ( + BlackjackActionButtons.map(action => ( + + )) + )} + {} +
+
+ + +
+ ); +} + +export default BettingControls; diff --git a/apps/frontend/src/features/games/blackjack/components/BlackjackBetVIz.tsx b/apps/frontend/src/features/games/blackjack/components/BlackjackBetVIz.tsx new file mode 100644 index 0000000..938d3a2 --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/components/BlackjackBetVIz.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import HandValue from './HandValue'; +import { calculateCardPosition, getCardTopLeft } from '../utils/widthUtils'; +import { useViewportType, ViewportType } from '@/common/hooks/useViewportType'; +import { BetData } from '@repo/common/types'; +import { + BlackjackGameState, + PlayerGameState, +} from '@repo/common/game-utils/blackjack/types.js'; +import { calculateHandValueWithSoft } from '@repo/common/game-utils/blackjack/utils.js'; +import PlayingCard from '@/components/ui/playing-card'; +import { cn } from '@/lib/utils'; +import { getBlackjackGameResult } from '../utils'; + +const BlackjackBetViz = ({ bet }: { bet: BetData }) => { + const viewportType = ViewportType.Mobile; + const gameState: BlackjackGameState = bet.gameState; + const { dealer, player } = gameState; + + const { rightPosition, centerOffset } = calculateCardPosition({ + totalCards: dealer.cards.length, + viewportType, + }); + + const renderPlayerHand = (hand: PlayerGameState, handIndex: number) => { + const totalCards = hand.cards.length; + const { rightPosition, centerOffset } = calculateCardPosition({ + totalCards, + viewportType, + }); + + const result = getBlackjackGameResult({ + hand, + dealerState: dealer, + isActive: false, + gameOver: true, + }); + + return ( +
+
+ + {hand.cards.map((card, index) => { + return ( +
+ +
+ ); + })} +
+
+ ); + }; + + return ( +
+ {/* Dealer Cards */} +
+
Dealer
+
+
+ + {dealer.cards.map((card, index) => { + return ( +
+ +
+ ); + })} +
+
+
+ {/* Player Cards */} +
+
Player
+
+ {player.map((hand, index) => renderPlayerHand(hand, index))} +
+
+
+ ); +}; + +export default BlackjackBetViz; diff --git a/apps/frontend/src/features/games/blackjack/components/BlackjackResultBreakdown.tsx b/apps/frontend/src/features/games/blackjack/components/BlackjackResultBreakdown.tsx new file mode 100644 index 0000000..1f36eda --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/components/BlackjackResultBreakdown.tsx @@ -0,0 +1,216 @@ +import { byteGenerator, getGeneratedFloats } from '@/lib/crypto'; +import { convertFloatsToGameEvents } from '@repo/common/game-utils/blackjack/utils.js'; +import { useQuery } from '@tanstack/react-query'; +import React, { Fragment, useMemo } from 'react'; +import chunk from 'lodash/chunk'; +import { HashLoader } from 'react-spinners'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { + CARD_DECK, + SUIT_TEXT, +} from '@repo/common/game-utils/cards/constants.js'; + +interface BlackjackResultBreakdownProps { + clientSeed?: string; + nonce?: number; + serverSeed?: string; +} + +const generateUniqueId = ( + prefix: string, + ...parts: (string | number)[] +): string => { + return `${prefix}-${parts.join('-')}`; +}; + +const BlackjackResultBreakdown: React.FC = ({ + clientSeed, + nonce, + serverSeed, +}) => { + const { data: hmacArray = [] } = useQuery({ + queryKey: ['hmacBuffer', serverSeed, clientSeed, nonce], + queryFn: async () => { + const bytes = await byteGenerator( + serverSeed ?? '', + `${clientSeed}:${nonce}`, + 7 + ); + return bytes; + }, + }); + + const { data: floats } = useQuery({ + queryKey: ['result-blackjack', serverSeed, clientSeed, nonce], + queryFn: async () => { + const result = await getGeneratedFloats({ + count: 52, + seed: serverSeed ?? '', + message: `${clientSeed}:${nonce}`, + }); + return result; + }, + }); + + const gameEvents = convertFloatsToGameEvents(floats); + + // Create unique identifiers for each byte in the hmacArray + const hmacByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + const chunkedHmacByteIds = chunk( + hmacByteIds, + Math.ceil(hmacByteIds.length / 7) + ); + + // Create unique identifiers for selected bytes + const selectedByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('selected-byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + const chunkedSelectedByteIds = chunk(selectedByteIds, 4); + + if (!serverSeed || !clientSeed || !floats) { + return ; + } + + return ( +
+
+ +

+ {gameEvents.map(event => ( +

+

{event}

+ + {SUIT_TEXT[CARD_DECK[event].suit]} + {CARD_DECK[event].rank} + +
+ ))} + {/* ( {drawnNumbers.join(', ')} ) */} +

+

+ {/* + ( {finalDrawnNumbers.join(', ')} ) + */} +

+
+ +
+ +
+ {chunkedHmacByteIds.map((chunkedHmacByteId, index) => ( +
+

+ {`HMAC_SHA256(${serverSeed}, ${clientSeed}:${nonce}:${index})`} +

+
+ {chunkedHmacByteId.map(({ byte, id }, chunkIndex) => ( +
= 16 && index === 6, + } + )} + key={id} + > + {byte.toString(16).padStart(2, '0')} + {byte} +
+ ))} +
+
+ ))} +
+
+ +
+ +
+
+ {chunkedSelectedByteIds.slice(0, 52).map((selectedBytes, index) => { + return ( +
+
+
{`(${selectedBytes.map(({ byte }) => byte).join(', ')}) -> [0, ..., ${52 - 1 - index}] = ${Math.floor(floats[index] * (52 - index))}`}
+ {selectedBytes.map(({ byte, id }, i) => ( + + + {i > 0 ? '+' : ''} + + + {(byte / 256 ** (i + 1)) + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + {`(${Array((3 - (byte.toString().length % 3)) % 3) + .fill('0') + .join('')}${byte} / (256 ^ ${i + 1}))`} + +
+ ))} + = + + {floats[index] + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + (× {52}) + + = + + {String((floats[index] * 52).toFixed(12)).split('.')[0]} + + . + {String((floats[index] * 52).toFixed(12)).split('.')[1]} + + +
+
+ ); + })} +
+
+
+
+ ); +}; + +export default BlackjackResultBreakdown; diff --git a/apps/frontend/src/features/games/blackjack/components/BlackjackResultPreview.tsx b/apps/frontend/src/features/games/blackjack/components/BlackjackResultPreview.tsx new file mode 100644 index 0000000..6f10f84 --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/components/BlackjackResultPreview.tsx @@ -0,0 +1,89 @@ +import { useViewportType } from '@/common/hooks/useViewportType'; +import PlayingCard from '@/components/ui/playing-card'; +import { CARD_DECK } from '@repo/common/game-utils/cards/constants.js'; +import React from 'react'; +import { calculateCardPosition, getCardTopLeft } from '../utils/widthUtils'; +import HandValue from './HandValue'; +import { calculateHandValueWithSoft } from '@repo/common/game-utils/blackjack/utils.js'; + +const BlackjackResultPreview = ({ result }: { result: number[] }) => { + const viewportType = useViewportType(); + const playerCards = [CARD_DECK[result[0]], CARD_DECK[result[1]]]; + const dealerCards = [CARD_DECK[result[2]], CARD_DECK[result[3]]]; + const remainingCards = result.slice(4).map(index => CARD_DECK[index]); + + const { rightPosition, centerOffset } = calculateCardPosition({ + totalCards: 2, + viewportType, + }); + + return ( +
+ {/* Dealer Cards */} +
+
Dealer
+
+
+ + {dealerCards.map((card, index) => { + return ( +
+ +
+ ); + })} +
+
+
+ {/* Player Cards */} +
+
Player
+
+
+ + {playerCards.map((card, index) => { + return ( +
+ +
+ ); + })} +
+
+
+ + {/* All 52 Cards */} +
+ {/* Scroll container */} + {/* Row that can overflow horizontally */} +
+ {remainingCards.map((card, index) => ( +
+ +
+ ))} +
+
+
+ ); +}; + +export default BlackjackResultPreview; diff --git a/apps/frontend/src/features/games/blackjack/components/GameTable.new.tsx b/apps/frontend/src/features/games/blackjack/components/GameTable.new.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/features/games/blackjack/components/GameTable.tsx b/apps/frontend/src/features/games/blackjack/components/GameTable.tsx new file mode 100644 index 0000000..fcf22f7 --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/components/GameTable.tsx @@ -0,0 +1,235 @@ +import { LayoutGroup } from 'motion/react'; +import type { PlayerGameState } from '@repo/common/game-utils/blackjack/types.js'; +import { + getCurrentActiveHandIndex, + calculateHandValueWithSoft, +} from '@repo/common/game-utils/blackjack/utils.js'; +import PlayingCard, { CardBorders } from '@/components/ui/playing-card'; +import useBlackjackStore from '../store/blackjackStore'; +import { FACE_DOWN_HIDDEN_DEALER_CARD } from '../const'; +import { getBlackjackGameResult } from '../utils'; +import HandValue from './HandValue'; +import { useViewportType } from '@/common/hooks/useViewportType'; +import { calculateCardPosition, getCardTopLeft } from '../utils/widthUtils'; +import { cn } from '@/lib/utils'; + +function GameTable(): JSX.Element { + const { gameState, gameOver, cardInDeck, flippedCards, incomingCards } = + useBlackjackStore(); + + const viewportType = useViewportType(); + + // Show dealer's hole card only when game is not active + const showDealerHoleCard = + gameState && + gameState.state.dealer.cards.length > 1 && + incomingCards.has(gameState.state.dealer.cards[1].id); + + const currentActiveHandIndex = getCurrentActiveHandIndex( + gameState?.state.player + ); + + const renderDealerHand = (): JSX.Element | null => { + if (!gameState?.state.dealer.cards.length) return null; + + // Calculate center offset: -48 - (totalCards - 1) * 20 + const totalCards = + gameState.state.dealer.cards.filter(card => incomingCards.has(card.id)) + .length + + (!showDealerHoleCard && incomingCards.has(FACE_DOWN_HIDDEN_DEALER_CARD) + ? 1 + : 0); + + const { rightPosition, centerOffset } = calculateCardPosition({ + totalCards, + viewportType, + }); + + return ( +
+
+ + flippedCards.has(card.id) + ) + )} + /> + {gameState.state.dealer.cards.map((card, index) => { + const isHoleCard = index === 1; + const shouldShowCard = !isHoleCard || showDealerHoleCard; + if (!incomingCards.has(card.id)) { + return; + } + + return ( +
+ +
+ ); + })} + {!showDealerHoleCard && + incomingCards.has(FACE_DOWN_HIDDEN_DEALER_CARD) ? ( +
+ +
+ ) : null} +
+
+ ); + }; + + const renderPlayerHand = ( + hand: PlayerGameState, + handIndex = 0 + ): JSX.Element => { + const isMultipleHands = hand.cards.length > 1; + + const totalCards = Math.max( + hand.cards.filter(card => incomingCards.has(card.id)).length, + 1 + ); + const { rightPosition, centerOffset } = calculateCardPosition({ + totalCards, + viewportType, + }); + + const result = + gameState !== null + ? getBlackjackGameResult({ + hand, + dealerState: gameState.state.dealer, + isActive: + currentActiveHandIndex === handIndex && + gameState.state.player.length > 1 && + hand.cards.every(card => flippedCards.has(card.id)), + gameOver, + }) + : CardBorders.TRANSPARENT; + return ( +
+
+ flippedCards.has(card.id)) + )} + /> + {hand.cards.map((card, cardIndex) => { + // Calculate center offset: -48 - (totalCards - 1) * 20 + + if (!incomingCards.has(card.id)) { + return; + } + + return ( +
+ +
+ ); + })} +
+
+ ); + }; + + const renderDeck = (): JSX.Element => ( +
+
+ {/* Stack effect with multiple card backs */} + {[0, 1, 2, 3].map(offset => ( +
+ +
+ ))} + {cardInDeck ? ( +
+ +
+ ) : null} +
+
+ ); + + return ( + + {renderDeck()} +
+ {/* Deck positioned on the top right */} + + {/* Dealer's hand at the top */} +
+ {renderDealerHand()} +
+ + {/* Player's hand(s) at the bottom */} +
+
1, + })} + > + {gameState?.state.player.map((hand, index) => + renderPlayerHand(hand, index) + )} +
+
+
+
+ ); +} + +export default GameTable; diff --git a/apps/frontend/src/features/games/blackjack/components/HandValue.tsx b/apps/frontend/src/features/games/blackjack/components/HandValue.tsx new file mode 100644 index 0000000..5a4a9e1 --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/components/HandValue.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { CardBorders } from '@/components/ui/playing-card'; +import { cn } from '@/lib/utils'; + +function HandValue({ + value, + rightPosition, + background = CardBorders.TRANSPARENT, + className, +}: { + value: number | string; + rightPosition: number; + background?: CardBorders; + className?: string; +}): JSX.Element | null { + if (!value) { + return null; + } + return ( + + {value} + + ); +} + +export default HandValue; diff --git a/apps/frontend/src/features/games/blackjack/const/index.ts b/apps/frontend/src/features/games/blackjack/const/index.ts new file mode 100644 index 0000000..17d09a7 --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/const/index.ts @@ -0,0 +1,5 @@ +const FACE_DOWN_CARD_IDS = ['deck-1', 'deck-2', 'deck-3', 'deck-4', 'deck-5']; + +const FACE_DOWN_HIDDEN_DEALER_CARD = 'face-down-hidden-dealer-card'; + +export { FACE_DOWN_CARD_IDS, FACE_DOWN_HIDDEN_DEALER_CARD }; diff --git a/apps/frontend/src/features/games/blackjack/hooks/useCardAnimations.ts b/apps/frontend/src/features/games/blackjack/hooks/useCardAnimations.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/features/games/blackjack/index.tsx b/apps/frontend/src/features/games/blackjack/index.tsx new file mode 100644 index 0000000..8cf777b --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Games } from '@/const/games'; +import GameSettingsBar from '../common/components/game-settings'; +import BettingControls from './components/BettingControls'; +import GameTable from './components/GameTable'; +import useBlackjackStore from './store/blackjackStore'; + +export default function Blackjack(): JSX.Element { + return ( +
+
+ +
+ +
+
+ +
+ ); +} diff --git a/apps/frontend/src/features/games/blackjack/store/blackjackStore.ts b/apps/frontend/src/features/games/blackjack/store/blackjackStore.ts new file mode 100644 index 0000000..504dd7d --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/store/blackjackStore.ts @@ -0,0 +1,210 @@ +import type { BlackjackPlayRoundResponse } from '@repo/common/game-utils/blackjack/types.js'; +import { create } from 'zustand'; +import { FACE_DOWN_HIDDEN_DEALER_CARD } from '../const'; + +interface BlackjackStore { + betAmount: number; + setBetAmount: (betAmount: number) => void; + + gameState: BlackjackPlayRoundResponse | null; + setGameState: ( + gameState: BlackjackPlayRoundResponse | null, + _flipped?: boolean + ) => void; + + // Animation state management + cardInDeck: string | null; + dealingQueue: string[]; + + flippedCards: Set; + incomingCards: Set; + + gameOver: boolean; + + // Actions + initializeGame: (backendState: BlackjackPlayRoundResponse) => Promise; + dealNextCard: () => Promise; + clearTransientCards: () => void; + playNextRoundHandler: (data: BlackjackPlayRoundResponse) => Promise; + setActiveGame: (activeGame: BlackjackPlayRoundResponse) => void; +} + +const delay = (ms: number): Promise => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +const useBlackjackStore = create((set, get) => ({ + betAmount: 0, + setBetAmount: betAmount => { + set({ betAmount }); + }, + gameState: null, + + gameOver: false, + + // Animation state + dealingQueue: [], + cardInDeck: null, + + flippedCards: new Set(), + incomingCards: new Set(), + + setGameState: gameState => { + set({ + gameState, + }); + }, + + initializeGame: async (backendState: BlackjackPlayRoundResponse) => { + // Clear existing cards + set({ + gameOver: false, + cardInDeck: null, + flippedCards: new Set(), + incomingCards: new Set(), + }); + + const { player, dealer } = backendState.state; + + const dealingQueue = [ + player[0].cards[0].id, + dealer.cards[0].id, + player[0].cards[1].id, + 'cards' in dealer && dealer.cards.length > 1 + ? dealer.cards[1].id + : FACE_DOWN_HIDDEN_DEALER_CARD, + ]; + + set({ dealingQueue }); + // Call dealNextCard but don't return the promise + await get().dealNextCard(); + + if (!backendState.active) { + set({ gameOver: true }); + } + }, + + dealNextCard: async () => { + const state = get(); + + if (state.dealingQueue.length === 0) { + return; + } + + // Step 1: Place card in deck + const nextCard = state.dealingQueue[0]; + const remainingQueue = state.dealingQueue.slice(1); + + set({ + cardInDeck: nextCard, + dealingQueue: remainingQueue, + }); + + await delay(200); // Wait for the card to be placed in deck + + set(currentState => ({ + incomingCards: new Set(currentState.incomingCards).add(nextCard), + cardInDeck: null, + })); + + await delay(400); // Wait for the card to be visible + + set(currentState => ({ + flippedCards: new Set(currentState.flippedCards).add(nextCard), + })); + + // Step 4: Deal the next card + await get().dealNextCard(); + }, + + playNextRoundHandler: async (data: BlackjackPlayRoundResponse) => { + const { player, dealer } = data.state; + const { active } = data; + + const playerRoundCards = player.flatMap(hand => + hand.cards.map(card => card.id) + ); + + const dealerRoundCards = dealer.cards.map(card => card.id); + + const newPlayerCards = playerRoundCards.filter( + cardId => !get().incomingCards.has(cardId) + ); + const newDealerCards = dealerRoundCards.filter( + cardId => !get().incomingCards.has(cardId) + ); + + // If game is over (not active) and dealer has a hidden card that needs to be revealed + if ( + !active && + get().incomingCards.has(FACE_DOWN_HIDDEN_DEALER_CARD) && + dealer.cards.length > 1 + ) { + set({ dealingQueue: newPlayerCards }); + await get().dealNextCard(); + const secondDealerCard = dealer.cards[1]; + + // Remove the face-down hidden card and add the actual second dealer card + set(currentState => { + const newIncomingCards = new Set(currentState.incomingCards); + + // Add the actual second dealer card (but don't flip it yet) + newIncomingCards.add(secondDealerCard.id); + + return { + incomingCards: newIncomingCards, + }; + }); + + // Add a delay before flipping the card to create the animation + await delay(200); + + set(currentState => ({ + flippedCards: new Set(currentState.flippedCards).add( + secondDealerCard.id + ), + })); + + // Filter out the second dealer card from new cards since we just handled it + const filteredNewDealerCards = newDealerCards.filter( + cardId => cardId !== secondDealerCard.id + ); + set({ dealingQueue: filteredNewDealerCards }); + } else { + const dealingQueue = [...newPlayerCards, ...newDealerCards]; + set({ dealingQueue }); + } + + await get().dealNextCard(); + + if (!data.active) { + set({ gameOver: true }); + } + }, + + clearTransientCards: () => { + set({ + dealingQueue: [], + cardInDeck: null, + }); + }, + + setActiveGame: (gameState: BlackjackPlayRoundResponse) => { + const cardIds = new Set([ + ...gameState.state.dealer.cards.map(card => card.id), + ...gameState.state.player.flatMap(hand => + hand.cards.map(card => card.id) + ), + FACE_DOWN_HIDDEN_DEALER_CARD, + ]); + set({ + flippedCards: new Set(cardIds), + incomingCards: new Set(cardIds), + gameState, + gameOver: false, + }); + }, +})); + +export default useBlackjackStore; diff --git a/apps/frontend/src/features/games/blackjack/utils/index.ts b/apps/frontend/src/features/games/blackjack/utils/index.ts new file mode 100644 index 0000000..0dd2e4e --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/utils/index.ts @@ -0,0 +1,39 @@ +import { BlackjackActions } from '@repo/common/game-utils/blackjack/types.js'; +import type { + SafeDealerGameState, + DealerGameState, + PlayerGameState, +} from '@repo/common/game-utils/blackjack/types.js'; +import { CardBorders } from '@/components/ui/playing-card'; + +export const getBlackjackGameResult = ({ + hand, + dealerState, + isActive, + gameOver, +}: { + hand: PlayerGameState; + dealerState: DealerGameState | SafeDealerGameState; + isActive: boolean; + gameOver: boolean; +}): CardBorders => { + const playerValue = hand.value; + if (isActive) { + return CardBorders.INFO; + } + + if (!gameOver) return CardBorders.TRANSPARENT; + const dealerValue = dealerState.value; + + if (playerValue > 21 || hand.actions.includes(BlackjackActions.BUST)) + return CardBorders.ERROR; + if ( + playerValue === 21 || + dealerValue > 21 || + dealerState.actions.includes(BlackjackActions.BUST) + ) { + return CardBorders.SUCCESS; + } + if (playerValue === dealerValue) return CardBorders.WARNING; + return playerValue > dealerValue ? CardBorders.SUCCESS : CardBorders.ERROR; +}; diff --git a/apps/frontend/src/features/games/blackjack/utils/widthUtils.ts b/apps/frontend/src/features/games/blackjack/utils/widthUtils.ts new file mode 100644 index 0000000..2ad31ab --- /dev/null +++ b/apps/frontend/src/features/games/blackjack/utils/widthUtils.ts @@ -0,0 +1,55 @@ +import { ViewportType } from '@/common/hooks/useViewportType'; + +export const CARD_WIDTH = { + [ViewportType.Mobile]: 48, + [ViewportType.Tablet]: 48, + [ViewportType.Desktop]: 96, +}; + +const CARD_TOP_OFFSET = { + [ViewportType.Mobile]: 12, + [ViewportType.Tablet]: 12, + [ViewportType.Desktop]: 20, +}; + +const CARD_LEFT_OFFSET = { + [ViewportType.Mobile]: 24, + [ViewportType.Tablet]: 24, + [ViewportType.Desktop]: 50, +}; + +export const calculateCardPosition = ({ + totalCards, + viewportType, +}: { + totalCards: number; + viewportType: ViewportType; +}) => { + const centerOffset = + -(CARD_WIDTH[viewportType] / 2) - + ((totalCards - 1) * CARD_LEFT_OFFSET[viewportType]) / 2; + const rightmostCardLeft = + (totalCards - 1) * CARD_LEFT_OFFSET[viewportType] + centerOffset; + const cardWidth = CARD_WIDTH[viewportType]; + const rightPosition = rightmostCardLeft + cardWidth; + + return { + rightPosition, + centerOffset, + }; +}; + +export const getCardTopLeft = ({ + viewportType, + index, + centerOffset, +}: { + viewportType: ViewportType; + index: number; + centerOffset: number; +}) => { + return { + top: index * CARD_TOP_OFFSET[viewportType], + left: index * CARD_LEFT_OFFSET[viewportType] + centerOffset, + }; +}; diff --git a/apps/frontend/src/features/games/common/components/BetAmountButton.tsx b/apps/frontend/src/features/games/common/components/BetAmountButton.tsx new file mode 100644 index 0000000..e9e41c3 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/BetAmountButton.tsx @@ -0,0 +1,21 @@ +import { Button } from '@/components/ui/button'; + +export function BetAmountButton({ + label, + onClick, + disabled, +}: { + label: string; + onClick: () => void; + disabled?: boolean; +}): JSX.Element { + return ( + + ); +} diff --git a/apps/frontend/src/features/games/common/components/BetAmountInput.tsx b/apps/frontend/src/features/games/common/components/BetAmountInput.tsx new file mode 100644 index 0000000..0e87784 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/BetAmountInput.tsx @@ -0,0 +1,54 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { BadgeDollarSign } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import type { BettingControlsProps } from './BettingControls'; +import { BetAmountButton } from './BetAmountButton'; + +export function BetAmountInput({ + betAmount, + onBetAmountChange, + isInputDisabled, +}: Pick & { + isInputDisabled?: boolean; +}): JSX.Element { + const queryClient = useQueryClient(); + const balance = queryClient.getQueryData(['balance']) || 0; + return ( +
+ +
+
+ } + min={0} + onChange={e => { + onBetAmountChange?.(Number(e.target.value)); + }} + step={1} + type="number" + value={betAmount} + wrapperClassName="h-10 rounded-r-none rounded-none rounded-l flex-1" + /> +
+ { + onBetAmountChange?.(betAmount ?? 0, 0.5); + }} + /> + balance : true + } + label="2×" + onClick={() => { + onBetAmountChange?.(betAmount ?? 0, 2); + }} + /> +
+
+ ); +} diff --git a/apps/frontend/src/features/games/common/components/BettingControls.tsx b/apps/frontend/src/features/games/common/components/BettingControls.tsx new file mode 100644 index 0000000..4745147 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/BettingControls.tsx @@ -0,0 +1,105 @@ +import { BadgeDollarSign } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { BetAmountInput } from './BetAmountInput'; + +export interface BettingControlsProps { + betAmount?: number; + profitOnWin?: number; + isPending?: boolean; + onBetAmountChange?: (amount: number, multiplier?: number) => void; + onBet?: () => Promise; + betButtonText?: string; + icon?: React.ReactNode; +} + +function ProfitDisplay({ + profitOnWin, +}: Pick): JSX.Element { + return ( +
+ + } + value={profitOnWin} + wrapperClassName="bg-input-disabled shadow-md" + /> +
+ ); +} + +export function BetButton({ + isPending, + disabled, + onClick, + loadingImage, + betButtonText, + icon, + animate = 'animate-spin', +}: Pick & { + disabled: boolean; + onClick: () => void; +} & { + loadingImage: string; + betButtonText?: string; + icon?: React.ReactNode; + animate?: string; +}): JSX.Element { + return ( + + ); +} + +export function BettingControls({ + betAmount, + profitOnWin, + isPending, + onBetAmountChange, + onBet, + betButtonText, + icon, +}: BettingControlsProps): JSX.Element { + const queryClient = useQueryClient(); + const balance = queryClient.getQueryData(['balance']); + const isDisabled = + (betAmount ?? 0) > (balance ?? 0) || (betAmount ?? 0) <= 0 || isPending; + + return ( +
+ + + void onBet?.()} + /> +
+ ); +} diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/ActiveSeeds.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/ActiveSeeds.tsx new file mode 100644 index 0000000..7871748 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/ActiveSeeds.tsx @@ -0,0 +1,73 @@ +import { CopyIcon } from 'lucide-react'; +import type { ProvablyFairStateResponse } from '@repo/common/types'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +function ActiveSeeds({ + activeSeeds, + isLoading, +}: { + activeSeeds?: ProvablyFairStateResponse; + isLoading: boolean; +}): JSX.Element { + const activeSeedInputs = [ + { + key: 'clientSeed', + label: 'Active Client Seed', + value: activeSeeds?.clientSeed, + isCopyActive: true, + }, + { + key: 'hashedServerSeed', + label: 'Active Server Seed (Hashed)', + value: activeSeeds?.hashedServerSeed, + isCopyActive: true, + }, + { + key: 'nonce', + label: 'Total bets made with this pair', + value: activeSeeds?.nonce, + isCopyActive: false, + }, + ]; + + return ( +
+ {activeSeedInputs.map(input => ( +
+ +
+
+ +
+ {input.isCopyActive ? ( + + ) : null} +
+
+ ))} +
+ ); +} + +export default ActiveSeeds; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/Overview.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/Overview.tsx new file mode 100644 index 0000000..f428708 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/Overview.tsx @@ -0,0 +1,52 @@ +import Documentation from '@/components/documentation'; +import Code from '@/components/documentation/code'; +import Heading from '@/components/documentation/heading'; +import Link from '@/components/documentation/link'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; +import { Link as RouterLink } from '@tanstack/react-router'; + +const Overview = () => { + return ( +
+ +
+ + Solving the Trust Issue with Online Gambling + + + The underlying concept of provable fairness is that players have the + ability to prove and verify that their results are fair and + unmanipulated. This is achieved through the use of a{' '} + + , along with cryptographic hashing. + + + The commitment scheme is used to ensure that the player has an + influence on all results generated. Cryptographic hashing is used to + ensure that the casino also remains honest to this commitment + scheme. Both concepts combined creates a trust-less environment when + gambling online. + + + This is simplified in the following representation: + + + fair result = operators input (hashed) + players input + +
+
+
+ + Learn more + +
+
+ ); +}; + +export default Overview; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/RotateSeedPair.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/RotateSeedPair.tsx new file mode 100644 index 0000000..6907a4f --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/RotateSeedPair.tsx @@ -0,0 +1,111 @@ +import type { ProvablyFairStateResponse } from '@repo/common/types'; +import { useState } from 'react'; +import { CopyIcon } from 'lucide-react'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { generateRandomString } from '@/lib/crypto'; +import { Link } from '@tanstack/react-router'; + +interface RotateSeedPairProps { + activeSeeds?: ProvablyFairStateResponse; + isLoading: boolean; + rotateSeedPair: (clientSeed: string) => void; +} + +function RotateSeedPair({ + activeSeeds, + isLoading, + rotateSeedPair, +}: RotateSeedPairProps): JSX.Element { + const [nextClientSeed, setNextClientSeed] = useState( + generateRandomString(10) + ); + + return ( +
+
Rotate Seed Pair
+
+ +
+
+ { + setNextClientSeed(e.target.value); + }} + value={nextClientSeed} + wrapperClassName={cn( + 'bg-brand-stronger border-brand-weaker shadow-none w-full pr-0 h-8 rounded-r-none' + )} + /> +
+ + +
+
+
+ +
+
+ +
+ + +
+
+ {!activeSeeds?.canRotate && ( +
+ You need to finish the following game(s) before you can rotate your + seed pair: + + {activeSeeds?.activeGames?.map((game, index) => ( + + + {game.charAt(0).toUpperCase() + game.slice(1)} + + {} + + ))} + +
+ )} +
+ ); +} + +export default RotateSeedPair; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/Seeds.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/Seeds.tsx new file mode 100644 index 0000000..e8cab5b --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/Seeds.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ProvablyFairStateResponse } from '@repo/common/types'; +import { toast } from 'react-hot-toast'; +import { isAxiosError } from 'axios'; +import { fetchActiveSeeds, fetchRotateSeedPair } from '@/api/user'; +import ActiveSeeds from './ActiveSeeds'; +import RotateSeedPair from './RotateSeedPair'; + +function Seeds({ + isPending, + activeSeeds, +}: { + activeSeeds?: ProvablyFairStateResponse; + isPending: boolean; +}): JSX.Element { + const queryClient = useQueryClient(); + + const { mutate: rotateSeedPair, isPending: isRotating } = useMutation({ + mutationFn: async (clientSeed: string) => { + const apiResponse = await fetchRotateSeedPair(clientSeed); + return apiResponse.data; + }, + onSuccess: data => { + queryClient.setQueryData(['active-seeds'], data); + }, + onError: (error: Error) => { + if (isAxiosError(error)) { + toast.error(error.response?.data?.message as string, { + style: { + borderRadius: '10px', + background: '#0f212e', + color: '#fff', + }, + }); + return; + } + toast.error( + 'Something went wrong while rotating the seed pair. Please try again later.' + ); + }, + }); + + return ( +
+ + +
+ ); +} + +export default Seeds; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/VerificationInputs.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/VerificationInputs.tsx new file mode 100644 index 0000000..fcda286 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/VerificationInputs.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from 'react'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; +import { Link, useLocation } from '@tanstack/react-router'; +import { Label } from '@/components/ui/label'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import type { GameMeta } from '@/lib/verificationOutcomes'; +import { getVerificationOutcome } from '@/lib/verificationOutcomes'; +import { Games, type Game } from '@/const/games'; +import CommonSelect from '@/components/ui/common-select'; + +export interface VerificationInputsState { + clientSeed: string; + serverSeed: string; + nonce: number; + meta?: GameMeta; +} +export interface MaybeVerificationInputsState { + clientSeed?: string; + serverSeed?: string; + nonce?: number; +} + +function VerificationInputs({ + setOutcome, + onSetVerificationInputs, + game, + initialInputState, +}: { + setOutcome: (outcome: string | number[] | null) => void; + onSetVerificationInputs?: (inputs: VerificationInputsState | null) => void; + game: Game; + initialInputState?: MaybeVerificationInputsState | null; +}): JSX.Element { + const { pathname } = useLocation(); + + const [meta, setMeta] = useState(null); + + const [verificationInputs, setVerificationInputs] = + useState({ + clientSeed: '', + serverSeed: '', + nonce: 1, + }); + + useEffect(() => { + if (initialInputState) { + setVerificationInputs({ + clientSeed: initialInputState.clientSeed ?? '', + serverSeed: initialInputState.serverSeed ?? '', + nonce: initialInputState.nonce ?? 1, + }); + } + }, [initialInputState]); + + const handleInputChange = ( + input: keyof VerificationInputsState, + value: string + ): void => { + setVerificationInputs(prev => ({ ...prev, [input]: value })); + }; + + const incrementNonce = (): void => { + setVerificationInputs(prev => ({ + ...prev, + nonce: Number(prev.nonce) + 1, + })); + }; + + const decrementNonce = (): void => { + setVerificationInputs(prev => ({ + ...prev, + nonce: Math.max(0, Number(prev.nonce) - 1), + })); + }; + + const getGameMeta = (): JSX.Element | null => { + switch (game) { + case Games.MINES: + return ( + { + setMeta({ minesCount: Number(value) }); + setVerificationInputs(prev => ({ + ...prev, + meta: { minesCount: Number(value) }, + })); + }} + options={Array.from({ length: 24 }, (_, i) => ({ + label: (i + 1).toString(), + value: (i + 1).toString(), + }))} + value={meta?.minesCount.toString() ?? '3'} + /> + ); + default: + return null; + } + }; + + useEffect(() => { + const { clientSeed, serverSeed, nonce } = verificationInputs; + if (!clientSeed || !serverSeed) { + setOutcome(null); + onSetVerificationInputs?.(null); + return; + } + + void (async () => { + try { + const outcome = await getVerificationOutcome({ + game, + clientSeed, + serverSeed, + nonce, + ...(meta ? { meta } : {}), + }); + setOutcome(outcome); + onSetVerificationInputs?.(verificationInputs); + } catch (error: unknown) { + return error; + } + })(); + }, [verificationInputs, setOutcome, onSetVerificationInputs, game, meta]); + + return ( +
+
+ +
+ { + handleInputChange('clientSeed', e.target.value); + }} + value={verificationInputs.clientSeed} + wrapperClassName={cn( + 'bg-brand-stonger h-8 border-brand-weaker shadow-none w-full pr-0 ' + )} + /> +
+
+
+ +
+ { + handleInputChange('serverSeed', e.target.value); + }} + value={verificationInputs.serverSeed} + wrapperClassName={cn( + 'bg-brand-stronger h-8 border-brand-weaker shadow-none w-full pr-0 ' + )} + /> +
+
+
+ +
+
+ { + handleInputChange('nonce', e.target.value); + }} + step={1} + type="number" + value={verificationInputs.nonce} + wrapperClassName={cn( + 'bg-brand-stronger h-8 border-brand-weaker shadow-none w-full pr-0 rounded-r-none' + )} + /> +
+ + + +
+
+
{getGameMeta()}
+ {!pathname.includes('/provably-fair/calculation') && ( + +

+ View calculation breakdown +

+ + )} +
+ ); +} + +export default VerificationInputs; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/VerificationResult.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/VerificationResult.tsx new file mode 100644 index 0000000..9fb1590 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/VerificationResult.tsx @@ -0,0 +1,110 @@ +import { HashLoader } from 'react-spinners'; +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import { NO_OF_TILES_KENO } from '@repo/common/game-utils/keno/constants.js'; +import DiceResultPreview from '@/features/games/dice/components/DiceResultPreview'; +import { Games, type Game } from '@/const/games'; +import RouletteWheel from '@/features/games/roulette/components/RouletteWheel'; +import InactiveGameTile from '@/features/games/mines/components/InactiveGameTile'; +import VerificationResultKenoTile from '@/features/games/keno/components/VerificationResultKenoTile'; +import BlackjackResultPreview from '@/features/games/blackjack/components/BlackjackResultPreview'; +import { cn } from '@/lib/utils'; +import { ViewportType } from '@/common/hooks/useViewportType'; +function VerificationResult({ + game, + outcome, +}: { + game: Game | null; + outcome: string | number[] | null; +}): JSX.Element | null { + const getResult = (): JSX.Element => { + switch (game) { + case Games.DICE: + return ; + // case Games.ROULETTE: + // return ( + //
+ //
+ // + //
+ //
+ // {outcome} + //
+ //
+ // ); + + case Games.MINES: { + if (typeof outcome === 'string' || !outcome) return <>{null}; + return ( +
+ {Array.from({ length: NO_OF_TILES }, (_, i) => i).map(number => ( + + ))} +
+ ); + } + case Games.KENO: { + if (typeof outcome === 'string' || !outcome) return <>{null}; + return ( +
+ {Array.from({ length: NO_OF_TILES_KENO }, (_, i) => i).map( + number => ( + + ) + )} +
+ ); + } + case Games.BLACKJACK: + if ( + typeof outcome === 'string' || + !outcome || + (Array.isArray(outcome) && outcome.length !== 52) + ) + return <>{null}; + return ; + + default: + return
Unknown game
; + } + }; + + return ( +
+ {outcome ? ( + getResult() + ) : ( +
+

+ More inputs are required to verify result +

+ +
+ )} +
+ ); +} + +export default VerificationResult; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/Verify.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/Verify.tsx new file mode 100644 index 0000000..1e56fc4 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/Verify.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import CommonSelect from '@/components/ui/common-select'; +import { GAMES_DROPDOWN_OPTIONS, type Game } from '@/const/games'; +import VerificationResult from './VerificationResult'; +import VerificationInputs from './VerificationInputs'; +import { Route } from '@/routes/__root'; +import { useSearch } from '@tanstack/react-router'; +import { GLOBAL_MODAL } from '@/features/global-modals/types'; + +function Verify({ game }: { game: Game }): JSX.Element { + const [outcome, setOutcome] = useState(null); + const [selectedGame, setSelectedGame] = useState(game); + + const search = useSearch({ + from: '__root__', + select: state => { + if (state?.modal === GLOBAL_MODAL.FAIRNESS) { + return { + clientSeed: state.clientSeed, + serverSeed: state.serverSeed, + nonce: state.nonce, + }; + } + }, + }); + + return ( + <> +
+ +
+
+ { + setSelectedGame(value as Game); + }} + options={GAMES_DROPDOWN_OPTIONS} + value={selectedGame} + /> + +
+ + ); +} + +export default Verify; diff --git a/apps/frontend/src/features/games/common/components/fairness-modal/index.tsx b/apps/frontend/src/features/games/common/components/fairness-modal/index.tsx new file mode 100644 index 0000000..4e769fb --- /dev/null +++ b/apps/frontend/src/features/games/common/components/fairness-modal/index.tsx @@ -0,0 +1,126 @@ +import { ScaleIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Games, type Game } from '@/const/games'; +import { cn } from '@/lib/utils'; +import Seeds from './Seeds'; +import Verify from './Verify'; +import { useNavigate, useRouter } from '@tanstack/react-router'; +import { fetchActiveSeeds } from '@/api/user'; +import { useQuery } from '@tanstack/react-query'; +import { GLOBAL_MODAL } from '@/features/global-modals/types'; +import Overview from './Overview'; + +export function FairnessModal({ + game, + show, + tab, +}: { + game?: Game; + show: boolean; + tab: 'seeds' | 'verify' | 'overview'; +}): JSX.Element { + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState<'seeds' | 'verify' | 'overview'>( + tab + ); + const navigate = useNavigate(); + + const { isPending, data: activeSeeds } = useQuery({ + queryKey: ['active-seeds'], + queryFn: async () => { + const apiResponse = await fetchActiveSeeds(); + return apiResponse.data; + }, + enabled: open, + }); + + useEffect(() => { + if (tab) { + setActiveTab(tab); + } + }, [tab]); + + const handleRemoveParams = () => { + // Navigate to the current route's path but with the specified search params removed. + // Remove 'iid' and 'modal' from the search params object before navigating + navigate({ + search: searchParam => + ({ + game: undefined, + modal: undefined, + clientSeed: searchParam.clientSeed, + serverSeed: searchParam.serverSeed, + nonce: searchParam.nonce, + }) as never, + }); + }; + + const onOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + handleRemoveParams(); + } + }; + + useEffect(() => { + setOpen(show); + }, [show]); + + return ( + + + + + + Fairness + + + { + setActiveTab(value as 'seeds' | 'verify' | 'overview'); + navigate({ + search: { + modal: GLOBAL_MODAL.FAIRNESS, + game, + tab: value, + } as never, + }); + }} + value={activeTab} + > + + Overview + Seeds + Verify + + + + + + + + + + + + + + + + ); +} diff --git a/apps/frontend/src/features/games/common/components/game-settings/index.tsx b/apps/frontend/src/features/games/common/components/game-settings/index.tsx new file mode 100644 index 0000000..ff27a92 --- /dev/null +++ b/apps/frontend/src/features/games/common/components/game-settings/index.tsx @@ -0,0 +1,33 @@ +import { SettingsIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import CommonTooltip from '@/components/ui/common-tooltip'; +import type { Game } from '@/const/games'; +import { Link } from '@tanstack/react-router'; +import { GLOBAL_MODAL } from '@/features/global-modals/types'; + +function GameSettingsBar({ game }: { game: Game }): JSX.Element { + return ( +
+
+ Game Settings

}> + +
+
+ +

+ Fairness +

+ +
+ ); +} + +export default GameSettingsBar; diff --git a/apps/frontend/src/features/games/dice/components/DiceBetViz.tsx b/apps/frontend/src/features/games/dice/components/DiceBetViz.tsx new file mode 100644 index 0000000..cf1dd12 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceBetViz.tsx @@ -0,0 +1,62 @@ +import { DiceState } from '@repo/common/game-utils/dice/types.js'; +import React from 'react'; +import DiceSlideNumbers from './DiceSlideNumbers'; +import { Slider } from '@/components/ui/dice-slider'; +import DiceResultSlide from './DiceResultSlide'; +import { diceGameControls, GameControlIds } from '../config/controls'; +import NumericInput from './NumericInput'; +import { + calculateMultiplier, + calculateWinningChance, +} from '@repo/common/game-utils/dice/utils.js'; +import RollInput from './RollInput'; + +const DiceBetViz = ({ gameState }: { gameState: DiceState }) => { + const { target, condition, result } = gameState; + + const getValue = (controlId: GameControlIds) => { + switch (controlId) { + case GameControlIds.MULTIPLIER: + return calculateMultiplier(target, condition); + case GameControlIds.ROLL: + return condition; + case GameControlIds.WIN_CHANCE: + return calculateWinningChance(target, condition); + default: + return null; + } + }; + + return ( +
+ +
+ + +
+
+ {diceGameControls.map(control => + control.type === 'numeric' ? ( + + ) : ( + + ) + )} +
+
+ ); +}; + +export default DiceBetViz; diff --git a/apps/frontend/src/features/games/dice/components/DiceGameControls.tsx b/apps/frontend/src/features/games/dice/components/DiceGameControls.tsx new file mode 100644 index 0000000..5b34c40 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceGameControls.tsx @@ -0,0 +1,108 @@ +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { Label } from '@/components/ui/label'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { + GameControl, + NumericControl, + RollControl, +} from '../config/controls'; +import type { DiceStore } from '../store/diceStore'; +import RollInput from './RollInput'; +import NumericInput from './NumericInput'; + +interface GameControlsProps { + controls: GameControl[]; + state: DiceStore; +} + +function NumericControlInput({ + control, + state, +}: { + control: NumericControl; + state: DiceStore; +}): JSX.Element { + const value = control.getValue(state); + const isValid = value >= control.min && value <= control.max; + const Icon = control.icon; + + return ( + { + control.setValue(state, Number(e.target.value)); + }} + icon={Icon} + tooltipContent={ + +

{control.getValidationMessage(value)}

+
+ } + /> + ); +} + +function RollControlInput({ + control, + state, +}: { + control: RollControl; + state: DiceStore; +}): JSX.Element { + const value = control.getValue(state); + const condition = control.getCondition(state); + const Icon = control.icon; + + return ( + { + control.onToggle(state); + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + control.onToggle(state); + } + }} + value={value} + icon={Icon} + /> + ); +} + +function ControlInput({ + control, + state, +}: { + control: GameControl; + state: DiceStore; +}): JSX.Element { + if (control.type === 'numeric') { + return ; + } + return ; +} + +export function DiceGameControls({ + controls, + state, +}: GameControlsProps): JSX.Element { + return ( +
+ {controls.map(control => ( + + ))} +
+ ); +} diff --git a/apps/frontend/src/features/games/dice/components/DiceResultBreakdown.tsx b/apps/frontend/src/features/games/dice/components/DiceResultBreakdown.tsx new file mode 100644 index 0000000..03c9dcf --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceResultBreakdown.tsx @@ -0,0 +1,165 @@ +import { Fragment, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { HashLoader } from 'react-spinners'; +import { calculateFinalOutcome } from '@repo/common/game-utils/dice/utils.js'; +import { getGeneratedFloats, byteGenerator } from '@/lib/crypto'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +// Simple function to +// generate a stable unique ID without using array indices +const generateUniqueId = ( + prefix: string, + ...parts: (string | number)[] +): string => { + return `${prefix}-${parts.join('-')}`; +}; + +interface DiceResultBreakdownProps { + nonce?: number; + serverSeed?: string; + clientSeed?: string; +} + +function DiceResultBreakdown({ + nonce, + serverSeed, + clientSeed, +}: DiceResultBreakdownProps): JSX.Element { + const { data: hmacArray = [] } = useQuery({ + queryKey: ['hmacBuffer', serverSeed, clientSeed, nonce], + queryFn: async () => { + const bytes = await byteGenerator( + serverSeed ?? '', + `${clientSeed}:${nonce}`, + 1 + ); + return bytes; + }, + }); + + const { data: outcome } = useQuery({ + queryKey: ['result-dice', serverSeed, clientSeed, nonce], + queryFn: async () => { + const result = await getGeneratedFloats({ + count: 1, + seed: serverSeed ?? '', + message: `${clientSeed}:${nonce}`, + }); + return result[0]; + }, + }); + + const selectedBytes = hmacArray.slice(0, 4); + + // Create unique identifiers for each byte in the hmacArray + const hmacByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + // Create unique identifiers for selected bytes + const selectedByteIds = useMemo(() => { + return selectedBytes.map((byte, idx) => ({ + byte, + id: generateUniqueId('selected-byte', byte, idx, Date.now()), + })); + }, [selectedBytes]); + + if (!serverSeed || !clientSeed || !outcome) { + return ; + } + + const finalOutcome = calculateFinalOutcome(outcome); + + return ( +
+
+ +

{finalOutcome}

+
+
+ +

+ {`HMAC_SHA256(${serverSeed}, ${clientSeed}:${nonce}:0)`} +

+
+ {hmacByteIds.map(({ byte, id }, index) => ( +
+ {byte.toString(16).padStart(2, '0')} + {byte} +
+ ))} +
+
+
+ +
+
+
{`(${selectedBytes.join(', ')}) -> [0, ..., 10000] = ${String(outcome * 10001).split('.')[0]}`}
+ {selectedByteIds.map(({ byte, id }, index) => ( + + + {index > 0 ? '+' : ''} + + + {(byte / 256 ** (index + 1)) + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + {`(${Array((3 - (byte.toString().length % 3)) % 3) + .fill('0') + .join('')}${byte} / (256 ^ ${index + 1}))`} + +
+ ))} + = + + {outcome + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + (× 10001) + + = + + {String(outcome * 10001).split('.')[0]} + + .{String(outcome * 10001).split('.')[1]} + + +
+
+
+
+ ); +} + +export default DiceResultBreakdown; diff --git a/apps/frontend/src/features/games/dice/components/DiceResultPillsCarousel.tsx b/apps/frontend/src/features/games/dice/components/DiceResultPillsCarousel.tsx new file mode 100644 index 0000000..269a53e --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceResultPillsCarousel.tsx @@ -0,0 +1,36 @@ +import type { DicePlaceBetResponse } from '@repo/common/game-utils/dice/types.js'; +import { cn } from '@/lib/utils'; + +interface DiceResultPillsCarouselProps { + results: DicePlaceBetResponse[]; +} + +export function DiceResultPillsCarousel({ + results, +}: DiceResultPillsCarouselProps): JSX.Element { + const getAnimationClass = (index: number, resultsLength: number): string => { + if (resultsLength <= 5) return 'animate-slideInLeft'; + return index === 0 + ? 'animate-slideOutLeft opacity-0' + : 'animate-slideInLeft'; + }; + + return ( +
+ {results.map(({ id, payoutMultiplier, state }, index) => ( + 0 + ? 'bg-[#00e600] text-black' + : 'bg-secondary-light' + )} + key={id} + > + {state.result} + + ))} +
+ ); +} diff --git a/apps/frontend/src/features/games/dice/components/DiceResultPreview.tsx b/apps/frontend/src/features/games/dice/components/DiceResultPreview.tsx new file mode 100644 index 0000000..14532c9 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceResultPreview.tsx @@ -0,0 +1,24 @@ +import { Slider } from '@/components/ui/dice-slider'; +import { Slider as ResultSlider } from '@/components/ui/dice-result-slider'; + +function DiceResultPreview({ result }: { result: number }): JSX.Element { + return ( +
+
+ {[0, 25, 50, 75, 100].map(value => ( +
+ {value} +
+ ))} +
+
+ +
+ +
+
+
+ ); +} + +export default DiceResultPreview; diff --git a/apps/frontend/src/features/games/dice/components/DiceResultSlide.tsx b/apps/frontend/src/features/games/dice/components/DiceResultSlide.tsx new file mode 100644 index 0000000..8d6c4e7 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceResultSlide.tsx @@ -0,0 +1,17 @@ +import { Slider as ResultSlider } from '@/components/ui/dice-result-slider'; + +const DiceResultSlide = ({ + success, + value, +}: { + success: boolean; + value: number[]; +}) => { + return ( +
+ +
+ ); +}; + +export default DiceResultSlide; diff --git a/apps/frontend/src/features/games/dice/components/DiceSlideNumbers.tsx b/apps/frontend/src/features/games/dice/components/DiceSlideNumbers.tsx new file mode 100644 index 0000000..bff562b --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceSlideNumbers.tsx @@ -0,0 +1,13 @@ +const DiceSlideNumbers = () => { + return ( +
+ {[0, 25, 50, 75, 100].map(value => ( +
+ {value} +
+ ))} +
+ ); +}; + +export default DiceSlideNumbers; diff --git a/apps/frontend/src/features/games/dice/components/DiceSlider.tsx b/apps/frontend/src/features/games/dice/components/DiceSlider.tsx new file mode 100644 index 0000000..40366e5 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/DiceSlider.tsx @@ -0,0 +1,40 @@ +import { Slider as ResultSlider } from '@/components/ui/dice-result-slider'; +import { Slider } from '@/components/ui/dice-slider'; +import useDiceStore from '../store/diceStore'; +import DiceSlideNumbers from './DiceSlideNumbers'; +import DiceResultSlide from './DiceResultSlide'; + +interface DiceSliderProps { + handleValueChange: (value: number[]) => void; + showResultSlider: boolean; +} + +function DiceSlider({ + handleValueChange, + showResultSlider, +}: DiceSliderProps): JSX.Element { + const { target, condition, results } = useDiceStore(); + const lastResult = results.at(-1); + + return ( + <> + +
+ + {showResultSlider && lastResult?.state.result ? ( + 0} + value={[lastResult.state.result]} + /> + ) : null} +
+ + ); +} + +export default DiceSlider; diff --git a/apps/frontend/src/features/games/dice/components/NumericInput.tsx b/apps/frontend/src/features/games/dice/components/NumericInput.tsx new file mode 100644 index 0000000..04467a5 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/NumericInput.tsx @@ -0,0 +1,77 @@ +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { Label } from '@/components/ui/label'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { Fragment } from 'react'; + +const NumericInput = ({ + isValid, + label, + min, + max, + step, + value, + onChange = () => {}, + icon: Icon, + tooltipContent = <>, + disabled = false, +}: { + isValid: boolean; + label: string; + min?: number; + max?: number; + step?: number; + value: number; + onChange?: (e: React.ChangeEvent) => void; + tooltipContent?: React.ReactNode; + icon: React.ElementType; + disabled?: boolean; +}) => { + return ( +
+ + + + {!isValid && ( + + } + min={min} + max={max} + onChange={onChange} + step={step} + type="number" + value={value} + wrapperClassName="border-red-500 hover:border-red-500" + /> + + )} + {tooltipContent} + + + {isValid ? ( + } + min={min} + onChange={onChange} + step={step} + max={max} + type="number" + value={value} + disabled={disabled} + wrapperClassName={cn({ + 'bg-brand-weaker h-8 mt-1': disabled, + })} + className={cn({ 'disabled:opacity-100': disabled })} + /> + ) : null} +
+ ); +}; + +export default NumericInput; diff --git a/apps/frontend/src/features/games/dice/components/ResultPillCarousel.tsx b/apps/frontend/src/features/games/dice/components/ResultPillCarousel.tsx new file mode 100644 index 0000000..68940f5 --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/ResultPillCarousel.tsx @@ -0,0 +1,5 @@ +function ResultPillCarousel(): JSX.Element { + return
ResultPillCarousel
; +} + +export default ResultPillCarousel; diff --git a/apps/frontend/src/features/games/dice/components/RollInput.tsx b/apps/frontend/src/features/games/dice/components/RollInput.tsx new file mode 100644 index 0000000..f15e85b --- /dev/null +++ b/apps/frontend/src/features/games/dice/components/RollInput.tsx @@ -0,0 +1,46 @@ +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import React from 'react'; + +const RollInput = ({ + condition, + label, + onClick, + onKeyDown, + value, + icon: Icon, + disabled = false, +}: { + condition: 'above' | 'below'; + label: string; + onClick?: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + value: number; + icon: React.ElementType; + disabled?: boolean; +}) => { + return ( +
+ +
+ {value} + +
+
+ ); +}; + +export default RollInput; diff --git a/apps/frontend/src/features/games/dice/config/controls.ts b/apps/frontend/src/features/games/dice/config/controls.ts new file mode 100644 index 0000000..de723e1 --- /dev/null +++ b/apps/frontend/src/features/games/dice/config/controls.ts @@ -0,0 +1,83 @@ +import { Percent, RefreshCw, XIcon } from 'lucide-react'; +import type { DiceCondition } from '@repo/common/game-utils/dice/types.js'; +import type { DiceStore } from '../store/diceStore'; + +export interface BaseControl { + id: string; + label: string; +} + +export interface NumericControl extends BaseControl { + type: 'numeric'; + icon: typeof XIcon; + min: number; + max: number; + step: number; + getValue: (state: DiceStore) => number; + setValue: (state: DiceStore, value: number) => void; + getValidationMessage: (value: number) => string; +} + +export interface RollControl extends BaseControl { + type: 'roll'; + icon: typeof RefreshCw; + getValue: (state: DiceStore) => number; + getCondition: (state: DiceStore) => DiceCondition; + onToggle: (state: DiceStore) => void; +} + +export type GameControl = NumericControl | RollControl; + +export enum GameControlIds { + MULTIPLIER = 'multiplier', + WIN_CHANCE = 'winChance', + ROLL = 'roll', +} + +export const diceGameControls: GameControl[] = [ + { + id: GameControlIds.MULTIPLIER, + type: 'numeric', + label: 'Multiplier', + icon: XIcon, + min: 1.0102, + max: 9900, + step: 0.0001, + getValue: state => state.multiplier, + setValue: (state, value) => { + state.setMultiplier(value); + }, + getValidationMessage: value => + `${value < 1.0102 ? 'Minimum' : 'Maximum'} is ${ + value < 1.0102 ? '1.0102' : '9900' + }`, + }, + { + id: GameControlIds.ROLL, + type: 'roll', + label: 'Roll', + icon: RefreshCw, + getValue: state => state.target, + getCondition: state => state.condition, + onToggle: state => { + state.toggleCondition(); + }, + }, + { + id: GameControlIds.WIN_CHANCE, + type: 'numeric', + label: 'Win chance', + icon: Percent, + min: 0.01, + max: 98, + step: 0.01, + getValue: state => state.winChance, + setValue: (state, value) => { + state.setWinningChance(value); + }, + getValidationMessage: value => + `${value < 0.01 ? 'Minimum' : 'Maximum'} is ${ + value < 0.01 ? '0.01' : '98' + }`, + }, +]; diff --git a/apps/frontend/src/features/games/dice/hooks/useBetting.ts b/apps/frontend/src/features/games/dice/hooks/useBetting.ts new file mode 100644 index 0000000..c7ad502 --- /dev/null +++ b/apps/frontend/src/features/games/dice/hooks/useBetting.ts @@ -0,0 +1,49 @@ +import type { UseMutateFunction } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { + DicePlaceBetRequestBody, + DicePlaceBetResponse, +} from '@repo/common/game-utils/dice/types.js'; +import type { ApiResponse } from '@repo/common/types'; +import { placeBet } from '@/api/games/dice'; +import { useAudio } from '@/common/hooks/useAudio'; +import win from '@/assets/audio/win.mp3'; + +interface UseDiceBettingProps { + setResult: (result: DicePlaceBetResponse) => void; + setLastResultId: (id: string) => void; +} + +interface UseDiceBettingResult { + mutate: UseMutateFunction< + ApiResponse, + Error, + DicePlaceBetRequestBody + >; + isPending: boolean; +} + +export function useDiceBetting({ + setResult, + setLastResultId, +}: UseDiceBettingProps): UseDiceBettingResult { + const { play: playWinSound } = useAudio(win); + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: placeBet, + onSuccess: (response: ApiResponse) => { + setLastResultId(Date.now().toString()); + setResult(response.data); + queryClient.setQueryData(['balance'], () => response.data.balance); + if (response.data.payoutMultiplier > 0) { + void playWinSound(); + } + }, + }); + + return { + mutate, + isPending, + }; +} diff --git a/apps/frontend/src/features/games/dice/hooks/useDiceAudio.ts b/apps/frontend/src/features/games/dice/hooks/useDiceAudio.ts new file mode 100644 index 0000000..6ae5acb --- /dev/null +++ b/apps/frontend/src/features/games/dice/hooks/useDiceAudio.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import bet from '@/assets/audio/bet.mp3'; +import rolling from '@/assets/audio/rolling.mp3'; +import { useAudio } from '@/common/hooks/useAudio'; + +export function useDiceAudio(isPending: boolean): { + playBetSound: () => Promise; +} { + const { play: playBetSound } = useAudio(bet); + const { playInfinite: playRollingSound, stop: stopRollingSound } = + useAudio(rolling); + + useEffect(() => { + if (isPending) { + playRollingSound(); + } else { + stopRollingSound(); + } + + return () => { + stopRollingSound(); + }; + }, [isPending, playRollingSound, stopRollingSound]); + + return { + playBetSound, + }; +} diff --git a/apps/frontend/src/features/games/dice/hooks/useResultSlider.ts b/apps/frontend/src/features/games/dice/hooks/useResultSlider.ts new file mode 100644 index 0000000..bd680c5 --- /dev/null +++ b/apps/frontend/src/features/games/dice/hooks/useResultSlider.ts @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useState } from 'react'; + +export function useResultSlider(): { + showResultSlider: boolean; + setLastResultId: (id: string) => void; +} { + const [showResultSlider, setShowResultSlider] = useState(false); + const [lastResultId, setLastResultId] = useState(null); + + const startTimer = useCallback(() => { + setShowResultSlider(true); + const timer = setTimeout(() => { + setShowResultSlider(false); + }, 3000); + return timer; + }, []); + + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (lastResultId) { + timer = startTimer(); + } + return () => { + if (timer) clearTimeout(timer); + }; + }, [lastResultId, startTimer]); + + return { + showResultSlider, + setLastResultId, + }; +} diff --git a/apps/frontend/src/features/games/dice/hooks/useSliderValue.ts b/apps/frontend/src/features/games/dice/hooks/useSliderValue.ts new file mode 100644 index 0000000..6bf728a --- /dev/null +++ b/apps/frontend/src/features/games/dice/hooks/useSliderValue.ts @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { useAudio } from '@/common/hooks/useAudio'; +import tick from '@/assets/audio/tick.mp3'; +import useDiceStore from '../store/diceStore'; + +export function useSliderValue(): { + handleValueChange: (value: number[]) => void; +} { + const { setTarget } = useDiceStore(); + const [previousValue, setPreviousValue] = useState(null); + const { playThrottled: playTickSound } = useAudio(tick, 0.8); + + const handleValueChange = (value: number[]): void => { + const newValue = value[0]; + + if (previousValue === newValue) return; + + if (previousValue !== null) { + playSoundForRange(previousValue, newValue); + } + + setPreviousValue(newValue); + setTarget(newValue); + }; + + const playSoundForRange = (start: number, end: number): void => { + const lowerBound = Math.min(start, end); + const upperBound = Math.max(start, end); + + for (let i = lowerBound; i <= upperBound; i++) { + playTickSound(); + } + }; + + return { handleValueChange }; +} diff --git a/apps/frontend/src/features/games/dice/index.tsx b/apps/frontend/src/features/games/dice/index.tsx new file mode 100644 index 0000000..81b6498 --- /dev/null +++ b/apps/frontend/src/features/games/dice/index.tsx @@ -0,0 +1,61 @@ +import useDiceStore from '@/features/games/dice/store/diceStore'; +import { Games } from '@/const/games'; +import { BettingControls } from '../common/components/BettingControls'; +import GameSettingsBar from '../common/components/game-settings'; +import { DiceResultPillsCarousel } from './components/DiceResultPillsCarousel'; +import { useDiceAudio } from './hooks/useDiceAudio'; +import { useDiceBetting } from './hooks/useBetting'; +import { useResultSlider } from './hooks/useResultSlider'; +import { useSliderValue } from './hooks/useSliderValue'; +import DiceSlider from './components/DiceSlider'; +import { DiceGameControls } from './components/DiceGameControls'; +import { diceGameControls } from './config/controls'; + +export function DiceGame(): JSX.Element { + const diceState = useDiceStore(); + const { betAmount, profitOnWin, results, setBetAmount, setResult } = + diceState; + const { playBetSound } = useDiceAudio(false); + const { showResultSlider, setLastResultId } = useResultSlider(); + const { handleValueChange } = useSliderValue(); + const { mutate, isPending } = useDiceBetting({ + setResult, + setLastResultId, + }); + + const handleBet = async (): Promise => { + await playBetSound(); + mutate({ + target: diceState.target, + condition: diceState.condition, + betAmount, + }); + }; + + return ( +
+
+ { + setBetAmount(amount * multiplier); + }} + profitOnWin={profitOnWin} + /> +
+ +
+ +
+ +
+
+ +
+ ); +} diff --git a/apps/frontend/src/features/games/dice/store/diceStore.ts b/apps/frontend/src/features/games/dice/store/diceStore.ts new file mode 100644 index 0000000..df2eb67 --- /dev/null +++ b/apps/frontend/src/features/games/dice/store/diceStore.ts @@ -0,0 +1,126 @@ +import { + calculateMultiplier, + calculateProfit, + calculateTargetFromChance, + calculateTargetWithMultiplier, + calculateWinningChance, +} from '@repo/common/game-utils/dice/index.js'; +import type { + DiceCondition, + DicePlaceBetResponse, +} from '@repo/common/game-utils/dice/index.js'; +import { create } from 'zustand'; + +const initalTarget = 50.5; +const initialCondition: DiceCondition = 'above'; + +export interface DiceStore { + betAmount: number; + profitOnWin: number; + multiplier: number; + target: number; + winChance: number; + condition: DiceCondition; + results: DicePlaceBetResponse[]; + setTarget: (target: number) => void; + toggleCondition: () => void; + setMultiplier: (multiplier: number) => void; + setWinningChance: (winChance: number) => void; + setBetAmount: (betAmount: number) => void; + setResult: (result: DicePlaceBetResponse) => void; +} + +const useDiceStore = create(set => ({ + betAmount: 0, + profitOnWin: 0, + multiplier: calculateMultiplier(initalTarget, initialCondition), + target: initalTarget, + winChance: calculateWinningChance(initalTarget, initialCondition), + condition: initialCondition, + results: [], + + setTarget: (target: number) => { + const clampedTarget = Math.min(98, Math.max(2, target)); + set(state => { + const multiplier = calculateMultiplier(clampedTarget, state.condition); + return { + ...state, + target: clampedTarget, + multiplier, + winChance: calculateWinningChance(clampedTarget, state.condition), + profitOnWin: calculateProfit(multiplier, state.betAmount), + }; + }); + }, + + setBetAmount: (betAmount: number) => { + set(state => ({ + ...state, + betAmount, + profitOnWin: calculateProfit(state.multiplier, betAmount), + })); + }, + + toggleCondition: () => { + set(state => { + const target = 100 - state.target; + const condition = state.condition === 'above' ? 'below' : 'above'; + const multiplier = calculateMultiplier(target, condition); + return { + ...state, + condition, + multiplier, + winChance: calculateWinningChance(target, condition), + target, + profitOnWin: calculateProfit(multiplier, state.betAmount), + }; + }); + }, + + setMultiplier: (multiplier: number) => { + const clampedMultiplier = Math.min(9900, multiplier); + set(state => { + const target = calculateTargetWithMultiplier( + clampedMultiplier, + state.condition + ); + return { + ...state, + multiplier, + winChance: calculateWinningChance(target, state.condition), + target, + profitOnWin: calculateProfit(multiplier, state.betAmount), + }; + }); + }, + + setWinningChance: (winChance: number) => { + const clampedWinChance = Math.min(99.99, Math.max(0.01, winChance)); + set(state => { + const target = calculateTargetFromChance( + clampedWinChance, + state.condition + ); + const multiplier = calculateMultiplier(target, state.condition); + return { + ...state, + winChance: clampedWinChance, + target, + multiplier, + profitOnWin: calculateProfit(multiplier, state.betAmount), + }; + }); + }, + + setResult: (result: DicePlaceBetResponse) => { + set(state => { + const newResults = [...state.results, result]; + if (newResults.length > 6) { + newResults.shift(); + } + return { ...state, results: newResults }; + }); + }, +})); + +export default useDiceStore; diff --git a/apps/frontend/src/features/games/keno/components/BettingControls.tsx b/apps/frontend/src/features/games/keno/components/BettingControls.tsx new file mode 100644 index 0000000..45e325f --- /dev/null +++ b/apps/frontend/src/features/games/keno/components/BettingControls.tsx @@ -0,0 +1,130 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { KenoRisk } from '@repo/common/game-utils/keno/types.js'; +import { NO_OF_TILES_KENO } from '@repo/common/game-utils/keno/constants.js'; +import CommonSelect from '@/components/ui/common-select'; +import { Button } from '@/components/ui/button'; +import { placeBet } from '@/api/games/keno'; +import { BetButton } from '../../common/components/BettingControls'; +import { BetAmountInput } from '../../common/components/BetAmountInput'; +import useKenoStore from '../store/kenoStore'; +import { KenoRiskDropdown } from '../const'; +import { useSelectedTiles } from '../store/kenoSelectors'; + +function BettingControls(): JSX.Element { + const { + betAmount, + setBetAmount, + kenoRisk, + setKenoRisk, + clearTiles, + updateSelectedTile, + setOutcome, + } = useKenoStore(); + + const selectedTiles = useSelectedTiles(); + + const queryClient = useQueryClient(); + const balance = queryClient.getQueryData(['balance']); + + const { mutate: placeBetMutation, isPending } = useMutation({ + mutationKey: ['keno-start-game'], + mutationFn: () => + placeBet({ + betAmount, + selectedTiles: Array.from(selectedTiles), + risk: kenoRisk, + }), + onSuccess: async ({ data }): Promise => { + const drawnNumbers = data.state.drawnNumbers; + + const updatePromises = Array.from(drawnNumbers.keys()).map(index => + sleep(100 * index).then(() => { + setOutcome({ + ...data, + state: { + ...data.state, + drawnNumbers: drawnNumbers.slice(0, index + 1), + }, + }); + }) + ); + + await Promise.all(updatePromises); + + queryClient.setQueryData(['balance'], data.balance); + }, + }); + + const isDisabled = + betAmount > (balance ?? 0) || betAmount <= 0 || selectedTiles.size === 0; + + // async function to update selected tile and sleep for 200ms + const sleep = (ms: number): Promise => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + + const autoPickTiles = async (): Promise => { + // Choose 10 random tiles between 1 and 40 + const randomTiles = new Set(); + while (randomTiles.size < 10) { + const randomTile = Math.floor(Math.random() * NO_OF_TILES_KENO) + 1; + randomTiles.add(randomTile); + } + + // Update tiles with delays + const tilesArray = Array.from(randomTiles); + const updatePromises = tilesArray.map((tile, index) => + sleep(index * 100).then(() => { + updateSelectedTile(tile); + }) + ); + + await Promise.all(updatePromises); + }; + + return ( +
+
+ { + setBetAmount(amount * multiplier); + }} + /> + { + setKenoRisk(value as KenoRisk); + }} + options={KenoRiskDropdown} + triggerClassName="h-10 text-sm font-medium bg-brand-stronger" + value={kenoRisk} + /> + + +
+ + +
+ ); +} + +export default BettingControls; diff --git a/apps/frontend/src/features/games/keno/components/KenoBetViz.tsx b/apps/frontend/src/features/games/keno/components/KenoBetViz.tsx new file mode 100644 index 0000000..dd840d4 --- /dev/null +++ b/apps/frontend/src/features/games/keno/components/KenoBetViz.tsx @@ -0,0 +1,46 @@ +import { NO_OF_TILES_KENO } from '@repo/common/game-utils/keno/constants.js'; +import { BetData } from '@repo/common/types'; +import React from 'react'; +import KenoTileUI from './KenoTileUI'; +import { Label } from '@/components/ui/label'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { KenoRiskLabels } from '../const'; +import { KenoRisk } from '@repo/common/game-utils/keno/types.js'; +import { cn } from '@/lib/utils'; + +const KenoBetViz = ({ bet }: { bet: BetData }) => { + const selectedTiles = new Set(bet.gameState.selectedTiles); + const drawnNumbers = new Set(bet.gameState.drawnNumbers); + return ( +
+
+ {Array.from({ length: NO_OF_TILES_KENO }, (_, i) => i).map(number => ( + + ))} +
+
+ + +
+
+ ); +}; + +export default KenoBetViz; diff --git a/apps/frontend/src/features/games/keno/components/KenoResultBreakdown.tsx b/apps/frontend/src/features/games/keno/components/KenoResultBreakdown.tsx new file mode 100644 index 0000000..22f1c88 --- /dev/null +++ b/apps/frontend/src/features/games/keno/components/KenoResultBreakdown.tsx @@ -0,0 +1,252 @@ +import { useQuery } from '@tanstack/react-query'; +import React, { Fragment, useMemo } from 'react'; +import { convertFloatsToGameEvents } from '@repo/common/game-utils/mines/utils.js'; +import { HashLoader } from 'react-spinners'; +import { chunk } from 'lodash'; +import { NO_OF_TILES_KENO } from '@repo/common/game-utils/keno/constants.js'; +import { calculateSelectedGems } from '@repo/common/game-utils/keno/utils.js'; +import { + byteGenerator, + getFisherYatesShuffle, + getGeneratedFloats, +} from '@/lib/crypto'; +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; + +const generateUniqueId = ( + prefix: string, + ...parts: (string | number)[] +): string => { + return `${prefix}-${parts.join('-')}`; +}; + +interface KenoResultBreakdownProps { + clientSeed?: string; + nonce?: number; + serverSeed?: string; +} +function KenoResultBreakdown({ + clientSeed, + nonce, + serverSeed, +}: KenoResultBreakdownProps): JSX.Element { + const { data: hmacArray = [] } = useQuery({ + queryKey: ['hmacBuffer', serverSeed, clientSeed, nonce], + queryFn: async () => { + const bytes = await byteGenerator( + serverSeed ?? '', + `${clientSeed}:${nonce}`, + 2 + ); + return bytes; + }, + }); + + const { data: floats } = useQuery({ + queryKey: ['result-keno', serverSeed, clientSeed, nonce], + queryFn: async () => { + const result = await getGeneratedFloats({ + count: 10, + seed: serverSeed ?? '', + message: `${clientSeed}:${nonce}`, + }); + return result; + }, + }); + + const gameEvents = convertFloatsToGameEvents(floats, NO_OF_TILES_KENO); + + const drawnNumbers = calculateSelectedGems(gameEvents, 10); + const finalDrawnNumbers = drawnNumbers.map(num => num + 1); + + const fisherYatesShuffle = getFisherYatesShuffle({ + gameEvents, + stopCount: 10, + totalEventsPossible: NO_OF_TILES_KENO, + }); + + // Create unique identifiers for each byte in the hmacArray + const hmacByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + const chunkedHmacByteIds = chunk( + hmacByteIds, + Math.ceil(hmacByteIds.length / 2) + ); + + // Create unique identifiers for selected bytes + const selectedByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('selected-byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + const chunkedSelectedByteIds = chunk(selectedByteIds, 4); + + if (!serverSeed || !clientSeed || !floats) { + return ; + } + + return ( +
+
+ +

+ ( {drawnNumbers.join(', ')} ) +

+
+ 1 =
+

+ + ( {finalDrawnNumbers.join(', ')} ) + +

+
+ +
+ +
+ {chunkedHmacByteIds.map((chunkedHmacByteId, index) => ( +
+

+ {`HMAC_SHA256(${serverSeed}, ${clientSeed}:${nonce}:${index})`} +

+
+ {chunkedHmacByteId.map(({ byte, id }, chunkIndex) => ( +
= 8 && index === 1, + } + )} + key={id} + > + {byte.toString(16).padStart(2, '0')} + {byte} +
+ ))} +
+
+ ))} +
+
+ +
+ +
+
+ {chunkedSelectedByteIds.slice(0, 10).map((selectedBytes, index) => { + return ( +
+
+
{`(${selectedBytes.map(({ byte }) => byte).join(', ')}) -> [0, ..., ${NO_OF_TILES_KENO - 1 - index}] = ${Math.floor(floats[index] * (NO_OF_TILES_KENO - index))}`}
+ {selectedBytes.map(({ byte, id }, i) => ( + + + {i > 0 ? '+' : ''} + + + {(byte / 256 ** (i + 1)) + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + {`(${Array((3 - (byte.toString().length % 3)) % 3) + .fill('0') + .join('')}${byte} / (256 ^ ${i + 1}))`} + +
+ ))} + = + + {floats[index] + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + (× {NO_OF_TILES_KENO - index}) + + = + + { + String( + (floats[index] * (NO_OF_TILES_KENO - index)).toFixed( + 12 + ) + ).split('.')[0] + } + + . + { + String( + ( + floats[index] * + (NO_OF_TILES_KENO - index) + ).toFixed(12) + ).split('.')[1] + } + + +
+
+ ); + })} +
+
+
+
+ +
+ {fisherYatesShuffle.map(({ array, chosenIndex }, index) => ( +
+
+ {array.map((byte, idx) => ( +
+ {byte} +
+ ))} +
+
+ ))} +
+
+
+ ); +} + +export default KenoResultBreakdown; diff --git a/apps/frontend/src/features/games/keno/components/KenoTile.tsx b/apps/frontend/src/features/games/keno/components/KenoTile.tsx new file mode 100644 index 0000000..f28c459 --- /dev/null +++ b/apps/frontend/src/features/games/keno/components/KenoTile.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils'; +import { useDrawnNumbers, useSelectedTiles } from '../store/kenoSelectors'; +import useKenoStore from '../store/kenoStore'; +import KenoTileUI from './KenoTileUI'; + +function KenoTile({ + isLoading, + index, +}: { + isLoading: boolean; + index: number; +}): JSX.Element { + const selectedTiles = useSelectedTiles(); + const drawnNumbers = useDrawnNumbers(); + + const { updateSelectedTile, outcome } = useKenoStore(); + + const isSelected = selectedTiles.has(index); + const isDrawn = drawnNumbers.has(index); + + const handleClick = (): void => { + if (isLoading) return; + updateSelectedTile(index); + }; + + const isTileDisabled = Boolean(outcome) && !isSelected; + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + handleClick(); + } + }} + isTileDisabled={isTileDisabled} + /> + ); +} + +export default KenoTile; diff --git a/apps/frontend/src/features/games/keno/components/KenoTileUI.tsx b/apps/frontend/src/features/games/keno/components/KenoTileUI.tsx new file mode 100644 index 0000000..a6bc15a --- /dev/null +++ b/apps/frontend/src/features/games/keno/components/KenoTileUI.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/lib/utils'; +import React from 'react'; + +const KenoTileUI = ({ + isSelected, + isDrawn, + index, + onClick, + onKeyDown, + isTileDisabled, + className, +}: { + isSelected: boolean; + isDrawn: boolean; + index: number; + onClick?: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + isTileDisabled: boolean; + className?: string; +}) => { + return ( +
+ {index} +
+ ); +}; + +export default KenoTileUI; diff --git a/apps/frontend/src/features/games/keno/components/VerificationResultKenoTile.tsx b/apps/frontend/src/features/games/keno/components/VerificationResultKenoTile.tsx new file mode 100644 index 0000000..94ba17d --- /dev/null +++ b/apps/frontend/src/features/games/keno/components/VerificationResultKenoTile.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils'; + +function VerificationResultKenoTile({ + drawnNumbers, + index, +}: { + drawnNumbers: Set; + index: number; +}): JSX.Element { + const isDrawn = drawnNumbers.has(index); + + return ( +
+ {index} +
+ ); +} + +export default VerificationResultKenoTile; diff --git a/apps/frontend/src/features/games/keno/const/index.ts b/apps/frontend/src/features/games/keno/const/index.ts new file mode 100644 index 0000000..a6b47f1 --- /dev/null +++ b/apps/frontend/src/features/games/keno/const/index.ts @@ -0,0 +1,20 @@ +export enum KenoRisk { + CLASSIC = 'classic', + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', +} + +export const KenoRiskLabels: Record = { + [KenoRisk.CLASSIC]: 'Classic', + [KenoRisk.LOW]: 'Low', + [KenoRisk.MEDIUM]: 'Medium', + [KenoRisk.HIGH]: 'High', +}; + +export const KenoRiskDropdown = Object.entries(KenoRiskLabels).map( + ([value, label]) => ({ + value: value as KenoRisk, + label, + }) +); diff --git a/apps/frontend/src/features/games/keno/index.tsx b/apps/frontend/src/features/games/keno/index.tsx new file mode 100644 index 0000000..63fd7d2 --- /dev/null +++ b/apps/frontend/src/features/games/keno/index.tsx @@ -0,0 +1,148 @@ +import { + KENO_PROBABILITY, + NO_OF_TILES_KENO, + PAYOUT_MULTIPLIERS, +} from '@repo/common/game-utils/keno/constants.js'; +import { BadgeDollarSign, BadgeDollarSignIcon } from 'lucide-react'; +import { Games } from '@/const/games'; +import { Label } from '@/components/ui/label'; +import GameSettingsBar from '../common/components/game-settings'; +import BettingControls from './components/BettingControls'; +import KenoTile from './components/KenoTile'; +import { + useDrawnNumbers, + usePayout, + usePayoutMultiplier, + useSelectedTiles, +} from './store/kenoSelectors'; +import useKenoStore from './store/kenoStore'; + +export function Keno(): JSX.Element { + const selectedTiles = useSelectedTiles(); + const { kenoRisk, hoveredTile, setHoveredTile, betAmount } = useKenoStore(); + const hoveredTilePayoutMultiplier = + hoveredTile !== null + ? PAYOUT_MULTIPLIERS[kenoRisk][selectedTiles.size][hoveredTile].toFixed(2) + : null; + + const payoutMultiplier = usePayoutMultiplier(); + const payout = usePayout(); + const drawnNumbers = useDrawnNumbers(); + + return ( +
+
+ + +
+
+
+ {Array.from({ length: NO_OF_TILES_KENO }, (_, i) => i).map( + number => ( + + ) + )} +
+
+ {selectedTiles.size === 0 ? ( +
+ Select 1-10 numbers to play +
+ ) : ( +
+
+ {Array.from( + { length: selectedTiles.size + 1 }, + (_, i) => i + ).map(tile => ( +
+ {PAYOUT_MULTIPLIERS[kenoRisk][selectedTiles.size][ + tile + ].toFixed(2)} + x +
+ ))} +
+
+ {hoveredTile !== null && ( +
+
+ +
+ {hoveredTilePayoutMultiplier}x +
+
+
+ +
+ {( + Number(hoveredTilePayoutMultiplier) * betAmount + ).toFixed(8)} + +
+
+
+ +
+ {KENO_PROBABILITY[selectedTiles.size][hoveredTile]} +
+
+
+ )} + {Array.from( + { length: selectedTiles.size + 1 }, + (_, i) => i + ).map(tile => ( +
{ + setHoveredTile(tile); + }} + onMouseLeave={() => { + setHoveredTile(null); + }} + > + {tile}x + diamond +
+ ))} +
+
+ )} + + {payoutMultiplier > 0 && drawnNumbers.size === 10 ? ( +
+

+ {payoutMultiplier.toFixed(2)}x +

+
+

+ {payout || 0} + +

+
+ ) : null} +
+
+ +
+ ); +} diff --git a/apps/frontend/src/features/games/keno/store/kenoSelectors.ts b/apps/frontend/src/features/games/keno/store/kenoSelectors.ts new file mode 100644 index 0000000..7ff0632 --- /dev/null +++ b/apps/frontend/src/features/games/keno/store/kenoSelectors.ts @@ -0,0 +1,21 @@ +import useKenoStore from './kenoStore'; + +export const useSelectedTiles = (): Set => + useKenoStore(state => { + return new Set(state.selectedTiles); + }); + +export const useDrawnNumbers = (): Set => + useKenoStore(state => { + return new Set(state.outcome?.state.drawnNumbers ?? []); + }); + +export const usePayoutMultiplier = (): number => + useKenoStore(state => { + return state.outcome?.payoutMultiplier ?? 0; + }); + +export const usePayout = (): number => + useKenoStore(state => { + return state.outcome?.payout ?? 0; + }); diff --git a/apps/frontend/src/features/games/keno/store/kenoStore.ts b/apps/frontend/src/features/games/keno/store/kenoStore.ts new file mode 100644 index 0000000..5e89973 --- /dev/null +++ b/apps/frontend/src/features/games/keno/store/kenoStore.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand'; +import type { + KenoResponse, + KenoRisk, +} from '@repo/common/game-utils/keno/types.js'; + +interface KenoStore { + betAmount: number; + setBetAmount: (betAmount: number) => void; + kenoRisk: KenoRisk; + setKenoRisk: (kenoRisk: KenoRisk) => void; + selectedTiles: number[]; + updateSelectedTile: (selectedTile: number) => void; + clearTiles: () => void; + hoveredTile: number | null; + setHoveredTile: (hoveredTile: number | null) => void; + outcome: null | KenoResponse; + setOutcome: (outcome: KenoResponse | null) => void; +} + +const useKenoStore = create(set => ({ + betAmount: 0, + setBetAmount: betAmount => { + set({ betAmount }); + }, + kenoRisk: 'classic', + setKenoRisk: kenoRisk => { + set({ kenoRisk }); + }, + selectedTiles: [], + clearTiles: () => { + set({ selectedTiles: [], outcome: null }); + }, + updateSelectedTile: (selectedTile: number) => { + set(state => { + if ( + state.selectedTiles.length >= 10 && + !state.selectedTiles.includes(selectedTile) + ) { + return state; + } + return { + selectedTiles: state.selectedTiles.includes(selectedTile) + ? state.selectedTiles.filter(t => t !== selectedTile) + : [...state.selectedTiles, selectedTile], + outcome: null, + }; + }); + }, + hoveredTile: null, + setHoveredTile: (hoveredTile: number | null) => { + set({ hoveredTile }); + }, + outcome: null, + setOutcome: (outcome: KenoResponse | null) => { + set({ outcome }); + }, +})); + +export default useKenoStore; diff --git a/apps/frontend/src/features/games/mines/components/ActiveGameTile.tsx b/apps/frontend/src/features/games/mines/components/ActiveGameTile.tsx new file mode 100644 index 0000000..64d00a5 --- /dev/null +++ b/apps/frontend/src/features/games/mines/components/ActiveGameTile.tsx @@ -0,0 +1,56 @@ +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; +import { useSelectedTiles } from '../store/minesSelectors'; + +function ActiveGameTile({ + onClick, + isLoading, + index, +}: { + isLoading: boolean; + onClick: () => void; + index: number; +}): JSX.Element { + const selectedTiles = useSelectedTiles(); + const hasDiamond = selectedTiles?.has(index); + + return ( + + {hasDiamond ? ( + diamond + ) : null} + + ); +} + +export default ActiveGameTile; diff --git a/apps/frontend/src/features/games/mines/components/BettingControls.tsx b/apps/frontend/src/features/games/mines/components/BettingControls.tsx new file mode 100644 index 0000000..92f4a0a --- /dev/null +++ b/apps/frontend/src/features/games/mines/components/BettingControls.tsx @@ -0,0 +1,197 @@ +import React, { useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import { startGame, getActiveGame, cashOut } from '@/api/games/mines'; +import CommonSelect from '@/components/ui/common-select'; +import { Label } from '@/components/ui/label'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { BetButton } from '../../common/components/BettingControls'; +import { BetAmountInput } from '../../common/components/BetAmountInput'; +import useMinesStore from '../store/minesStore'; +import { useIsGameActive, useLastRound } from '../store/minesSelectors'; + +function BettingControls({ + pickRandomTile, +}: { + pickRandomTile: () => void; +}): JSX.Element { + const { betAmount, setBetAmount, minesCount, setMinesCount, setGameState } = + useMinesStore(); + + const queryClient = useQueryClient(); + const balance = queryClient.getQueryData(['balance']); + + const { + isPending: isFetchingActiveGame, + data: activeGame, + isError, + } = useQuery({ + queryKey: ['mines-active-game'], + queryFn: getActiveGame, + retry: false, + }); + + const { mutate: cashout, isPending: isCashingOut } = useMutation({ + mutationKey: ['mines-cashout'], + mutationFn: cashOut, + onSuccess: ({ data }) => { + setGameState(data); + queryClient.setQueryData(['balance'], data.balance); + }, + }); + + const { mutate: start, isPending: isStartingGame } = useMutation({ + mutationKey: ['mines-start-game'], + mutationFn: () => startGame({ betAmount, minesCount }), + onSuccess: ({ data }) => { + setGameState(data); + setBetAmount(Number(data.betAmount)); + if (data.balance) { + queryClient.setQueryData(['balance'], data.balance); + } + }, + }); + + const isDisabled = betAmount > (balance ?? 0) || betAmount <= 0; + + const isGameActive = useIsGameActive(); + const lastRound = useLastRound(); + useEffect(() => { + if (isError) { + setGameState(null); + setBetAmount(0); + return; + } + if (activeGame) { + setGameState(activeGame.data || null); + setBetAmount(Number(activeGame.data?.betAmount || 0)); + } + }, [activeGame, isError, setGameState, setBetAmount]); + + return ( +
+
+ { + setBetAmount(amount * multiplier); + }} + /> + {isGameActive ? ( +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ ) : ( + { + setMinesCount(Number(value)); + }} + options={Array.from({ length: 24 }, (_, i) => ({ + label: (i + 1).toString(), + value: (i + 1).toString(), + }))} + triggerClassName="h-10 text-sm font-medium bg-brand-stronger" + value={minesCount.toString()} + /> + )} +
+ + {isGameActive ? ( + { + cashout(); + }} + /> + ) : ( + + )} +
+ ); +} + +export default BettingControls; diff --git a/apps/frontend/src/features/games/mines/components/InactiveGameTile.tsx b/apps/frontend/src/features/games/mines/components/InactiveGameTile.tsx new file mode 100644 index 0000000..51b9604 --- /dev/null +++ b/apps/frontend/src/features/games/mines/components/InactiveGameTile.tsx @@ -0,0 +1,118 @@ +import type { MinesRound } from '@repo/common/game-utils/mines/types.js'; +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface InactiveGameTileProps { + index: number; + isGameLost: boolean; + mines: Set | null; + selectedTiles?: Set | null; + lastRound?: MinesRound | null; + isPreview?: boolean; + className?: string; +} +function InactiveGameTile({ + index, + isGameLost, + mines, + selectedTiles, + lastRound, + isPreview = false, + className = '', +}: InactiveGameTileProps): JSX.Element { + const renderTile = (): JSX.Element => { + if (isPreview) { + if (mines?.has(index)) + return ( + diamond + ); + return ( + diamond + ); + } + if (isGameLost) { + if (index === lastRound?.selectedTileIndex) + return ( + <> + diamond + diamond + + ); + if (mines?.has(index)) + return ( + diamond + ); + if (selectedTiles?.has(index)) + return ( + diamond + ); + return ( + diamond + ); + } + if (selectedTiles?.has(index)) + return ( + diamond + ); + if (mines?.has(index)) + return ( + diamond + ); + return ( + diamond + ); + }; + return ( +
+ {renderTile()} +
+ ); +} + +export default InactiveGameTile; diff --git a/apps/frontend/src/features/games/mines/components/MinesBetViz.tsx b/apps/frontend/src/features/games/mines/components/MinesBetViz.tsx new file mode 100644 index 0000000..ad5c007 --- /dev/null +++ b/apps/frontend/src/features/games/mines/components/MinesBetViz.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import MinesContainer from './MinesContainer'; +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import InactiveGameTile from './InactiveGameTile'; +import { BetData } from '@repo/common/types'; + +const MinesBetViz = ({ bet }: { bet: BetData }) => { + return ( + + {Array.from({ length: NO_OF_TILES }, (_, i) => i).map(number => ( + 0, + isGameLost: bet.payoutMultiplier === 0, + mines: new Set(bet.gameState.mines), + selectedTiles: new Set( + bet.gameState.rounds.map( + ({ selectedTileIndex }: { selectedTileIndex: number }) => + selectedTileIndex + ) + ), + lastRound: bet.gameState.rounds.at(-1) || null, + }} + className="size-16 md:size-16 lg:size-16" + /> + ))} + + ); +}; + +export default MinesBetViz; diff --git a/apps/frontend/src/features/games/mines/components/MinesContainer.tsx b/apps/frontend/src/features/games/mines/components/MinesContainer.tsx new file mode 100644 index 0000000..24c716e --- /dev/null +++ b/apps/frontend/src/features/games/mines/components/MinesContainer.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const MinesContainer = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +export default MinesContainer; diff --git a/apps/frontend/src/features/games/mines/components/MinesResultBreakdown.tsx b/apps/frontend/src/features/games/mines/components/MinesResultBreakdown.tsx new file mode 100644 index 0000000..1e44a06 --- /dev/null +++ b/apps/frontend/src/features/games/mines/components/MinesResultBreakdown.tsx @@ -0,0 +1,252 @@ +import { useQuery } from '@tanstack/react-query'; +import React, { Fragment, useMemo } from 'react'; +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import { + calculateMines, + convertFloatsToGameEvents, +} from '@repo/common/game-utils/mines/utils.js'; +import { HashLoader } from 'react-spinners'; +import { chunk } from 'lodash'; +import { + byteGenerator, + getFisherYatesShuffle, + getGeneratedFloats, +} from '@/lib/crypto'; +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; + +const generateUniqueId = ( + prefix: string, + ...parts: (string | number)[] +): string => { + return `${prefix}-${parts.join('-')}`; +}; + +interface MinesResultBreakdownProps { + clientSeed?: string; + nonce?: number; + serverSeed?: string; + minesCount?: number; +} +function MinesResultBreakdown({ + clientSeed, + nonce, + serverSeed, + minesCount, +}: MinesResultBreakdownProps): JSX.Element { + const { data: hmacArray = [] } = useQuery({ + queryKey: ['hmacBuffer', serverSeed, clientSeed, nonce, minesCount], + queryFn: async () => { + const bytes = await byteGenerator( + serverSeed ?? '', + `${clientSeed}:${nonce}`, + 3 + ); + return bytes; + }, + }); + + const { data: floats } = useQuery({ + queryKey: ['result-mines', serverSeed, clientSeed, nonce, minesCount], + queryFn: async () => { + const result = await getGeneratedFloats({ + count: NO_OF_TILES - 1, + seed: serverSeed ?? '', + message: `${clientSeed}:${nonce}`, + }); + return result; + }, + }); + + const gameEvents = convertFloatsToGameEvents(floats, NO_OF_TILES); + + const allMines = calculateMines(gameEvents, NO_OF_TILES - 1); + + const mines = allMines.slice(0, minesCount ?? 3); + + const fisherYatesShuffle = getFisherYatesShuffle({ + gameEvents, + stopCount: NO_OF_TILES - 1, + totalEventsPossible: NO_OF_TILES, + }); + + // Create unique identifiers for each byte in the hmacArray + const hmacByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + const chunkedHmacByteIds = chunk( + hmacByteIds, + Math.ceil(hmacByteIds.length / 3) + ); + // Create unique identifiers for selected bytes + const selectedByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('selected-byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + const chunkedSelectedByteIds = chunk(selectedByteIds, 4); + + if (!serverSeed || !clientSeed || !floats) { + return ; + } + + return ( +
+
+ +

+ Values:{' '} + {mines.join(', ')} +

+
+
+

x = (value mod 5) + 1

+

y = 5 - floor(value / 5)

+

(x, y) starts from bottom left

+
+
+ Mines Coordinates: + + {mines + .map(mine => `(${(mine % 5) + 1}, ${5 - Math.floor(mine / 5)})`) + .join(', ')} + +
+
+ +
+ {chunkedHmacByteIds.map((chunkedHmacByteId, index) => ( +
+

+ {`HMAC_SHA256(${serverSeed}, ${clientSeed}:${nonce}:${index})`} +

+
+ {chunkedHmacByteId.map(({ byte, id }) => ( +
+ {byte.toString(16).padStart(2, '0')} + {byte} +
+ ))} +
+
+ ))} +
+
+
+ +
+ {chunkedSelectedByteIds.map((selectedBytes, index) => { + return ( +
+
+
{`(${selectedBytes.map(({ byte }) => byte).join(', ')}) -> [0, ..., ${NO_OF_TILES - 1 - index}] = ${Math.floor(floats[index] * (NO_OF_TILES - index))}`}
+ {selectedBytes.map(({ byte, id }, i) => ( + + + {i > 0 ? '+' : ''} + + + {(byte / 256 ** (i + 1)) + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + {`(${Array((3 - (byte.toString().length % 3)) % 3) + .fill('0') + .join('')}${byte} / (256 ^ ${i + 1}))`} + +
+ ))} + = + + {floats[index] + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + (× {NO_OF_TILES - index}) + + = + + { + String( + (floats[index] * (NO_OF_TILES - index)).toFixed(12) + ).split('.')[0] + } + + . + { + String( + (floats[index] * (NO_OF_TILES - index)).toFixed(12) + ).split('.')[1] + } + + +
+
+ ); + })} +
+
+
+ +
+ {fisherYatesShuffle.map(({ array, chosenIndex }, index) => ( +
+
+ {array.map((byte, idx) => ( +
+ {byte} +
+ ))} +
+
+ ))} +
+
+
+ ); +} + +export default MinesResultBreakdown; diff --git a/apps/frontend/src/features/games/mines/index.tsx b/apps/frontend/src/features/games/mines/index.tsx new file mode 100644 index 0000000..d590bca --- /dev/null +++ b/apps/frontend/src/features/games/mines/index.tsx @@ -0,0 +1,123 @@ +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { BadgeDollarSignIcon } from 'lucide-react'; +import { Games } from '@/const/games'; +import { playRound } from '@/api/games/mines'; +import GameSettingsBar from '../common/components/game-settings'; +import BettingControls from './components/BettingControls'; +import useMinesStore from './store/minesStore'; +import { + useIsGameActive, + useIsGameLost, + useIsGameWon, + useLastRound, + useMines, + usePayoutMultiplier, + useSelectedTiles, + useTotalPayout, +} from './store/minesSelectors'; +import ActiveGameTile from './components/ActiveGameTile'; +import InactiveGameTile from './components/InactiveGameTile'; + +export function Mines(): JSX.Element { + const { setGameState, gameState } = useMinesStore(); + const [loadingTiles, setLoadingTiles] = useState>(new Set()); + const { mutate: play } = useMutation({ + mutationKey: ['mines-play-round'], + mutationFn: (selectedTileIndex: number) => playRound(selectedTileIndex), + onSuccess: ({ data }) => { + setGameState(data); + setLoadingTiles(prev => { + const newSet = new Set(prev); + data.state.rounds.forEach(round => { + newSet.delete(round.selectedTileIndex); + }); + return newSet; + }); + }, + }); + + const isGameActive = useIsGameActive(); + + const isGameWon = useIsGameWon(); + const isGameLost = useIsGameLost(); + const mines = useMines(); + const selectedTiles = useSelectedTiles(); + const lastRound = useLastRound(); + const payoutMultiplier = usePayoutMultiplier(); + const payout = useTotalPayout(); + + const pickRandomTile = () => { + if (!gameState) return; + const availableTiles = Array.from( + { length: NO_OF_TILES }, + (_, i) => i + ).filter(index => !selectedTiles?.has(index) && !loadingTiles.has(index)); + if (availableTiles.length === 0) return; + const randomIndex = Math.floor(Math.random() * availableTiles.length); + const tileToPick = availableTiles[randomIndex]; + setLoadingTiles(prev => { + const newSet = new Set(prev); + newSet.add(tileToPick); + return newSet; + }); + play(tileToPick); + }; + return ( +
+
+ + +
+
+ {Array.from({ length: NO_OF_TILES }, (_, i) => i).map(number => + isGameActive || !gameState ? ( + { + if (isGameActive) { + setLoadingTiles(prev => { + const newSet = new Set(prev); + newSet.add(number); + return newSet; + }); + play(number); + } + }} + /> + ) : ( + + ) + )} +
+ {isGameWon ? ( +
+

+ {payoutMultiplier || '1.00'}x +

+
+

+ {payout || 0} + +

+
+ ) : null} +
+
+ +
+ ); +} diff --git a/apps/frontend/src/features/games/mines/store/minesSelectors.ts b/apps/frontend/src/features/games/mines/store/minesSelectors.ts new file mode 100644 index 0000000..c9815d0 --- /dev/null +++ b/apps/frontend/src/features/games/mines/store/minesSelectors.ts @@ -0,0 +1,54 @@ +import type { MinesRound } from '@repo/common/game-utils/mines/types.js'; +import useMinesStore from './minesStore'; + +export const useIsGameActive = (): boolean => + useMinesStore(state => state.gameState?.active ?? false); + +export const useIsGameWon = (): boolean => { + const isGameActive = useIsGameActive(); + return useMinesStore(state => + !isGameActive && state.gameState && 'payoutMultiplier' in state.gameState + ? Boolean(state.gameState.payoutMultiplier > 0) + : false + ); +}; + +export const useIsGameLost = (): boolean => { + const isGameActive = useIsGameActive(); + const isGameWon = useIsGameWon(); + return !isGameActive && !isGameWon; +}; + +export const useLastRound = (): MinesRound | null => + useMinesStore(state => state.gameState?.state.rounds.at(-1) ?? null); + +export const useTotalPayout = (): number | null => + useMinesStore(state => + state.gameState && 'payout' in state.gameState + ? state.gameState.payout + : null + ); + +export const usePayoutMultiplier = (): number | null => + useMinesStore(state => + state.gameState && 'payoutMultiplier' in state.gameState + ? state.gameState.payoutMultiplier + : null + ); + +export const useSelectedTiles = (): Set | null => + useMinesStore(state => { + if (!state.gameState) return null; + return new Set( + state.gameState.state.rounds.map(round => round.selectedTileIndex) + ); + }); + +export const useMines = (): Set | null => + useMinesStore(state => { + if (!state.gameState) return null; + return new Set(state.gameState.state.mines); + }); + +export const useMinesCount = (): number | null => + useMinesStore(state => state.gameState?.state.minesCount ?? null); diff --git a/apps/frontend/src/features/games/mines/store/minesStore.ts b/apps/frontend/src/features/games/mines/store/minesStore.ts new file mode 100644 index 0000000..30687cf --- /dev/null +++ b/apps/frontend/src/features/games/mines/store/minesStore.ts @@ -0,0 +1,33 @@ +import type { + MinesGameOverResponse, + MinesPlayRoundResponse, +} from '@repo/common/game-utils/mines/types.js'; +import { create } from 'zustand'; + +interface MinesStore { + betAmount: number; + setBetAmount: (betAmount: number) => void; + minesCount: number; + setMinesCount: (minesCount: number) => void; + gameState: MinesPlayRoundResponse | MinesGameOverResponse | null; + setGameState: ( + gameState: MinesPlayRoundResponse | MinesGameOverResponse | null + ) => void; +} + +const useMinesStore = create(set => ({ + betAmount: 0, + setBetAmount: betAmount => { + set({ betAmount }); + }, + minesCount: 3, + setMinesCount: minesCount => { + set({ minesCount }); + }, + gameState: null, + setGameState: gameState => { + set({ gameState }); + }, +})); + +export default useMinesStore; diff --git a/apps/frontend/src/features/games/plinkoo.tsx b/apps/frontend/src/features/games/plinkoo.tsx new file mode 100644 index 0000000..d5486d0 --- /dev/null +++ b/apps/frontend/src/features/games/plinkoo.tsx @@ -0,0 +1,8 @@ +export function Plinkoo(): JSX.Element { + return ( +
+

Plinkoo Game

+

Plinkoo game coming soon...

+
+ ); +} diff --git a/apps/frontend/src/features/games/roulette/components/BettingControls.tsx b/apps/frontend/src/features/games/roulette/components/BettingControls.tsx new file mode 100644 index 0000000..c350859 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/BettingControls.tsx @@ -0,0 +1,48 @@ +import { BetAmountInput } from '../../common/components/BetAmountInput'; +import { BetButton } from '../../common/components/BettingControls'; +import useRouletteStore from '../store/rouletteStore'; +import ChipCarousel from './ChipCarousel'; + +interface BettingControlsProps { + betButtonText?: string; + icon?: React.ReactNode; + isDisabled: boolean; + isPending: boolean; + onBet: () => void; +} + +function BettingControls({ + isDisabled, + isPending, + onBet, + betButtonText, + icon, +}: BettingControlsProps): JSX.Element { + const { betAmount, setBetAmount, multiplyBets } = useRouletteStore(); + return ( +
+ + { + setBetAmount(amount * 100 * multiplier); + multiplyBets(multiplier); + }} + /> + { + if (isDisabled) return; + onBet(); + }} + /> +
+ ); +} + +export default BettingControls; diff --git a/apps/frontend/src/features/games/roulette/components/Chip.tsx b/apps/frontend/src/features/games/roulette/components/Chip.tsx new file mode 100644 index 0000000..13d3060 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/Chip.tsx @@ -0,0 +1,117 @@ +import { useMemo } from 'react'; +import { BadgeDollarSignIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatCompactNumber, getYellowToRedColor } from '@/lib/formatters'; +import CommonTooltip from '@/components/ui/common-tooltip'; + +interface ChipProps { + size?: number; + value?: number; + customColor?: string; + disabled?: boolean; + onClick?: () => void; + isSelected?: boolean; + id?: string; +} + +function Chip({ + size = 10, + value = 1, + customColor, + disabled, + onClick, + isSelected, + id, +}: ChipProps): JSX.Element { + // Calculate color based on value + // Scale with logarithmic values to better handle a wide range of chip values + // Example ranges: + // 1 to 10 = bright yellow + // 100 to 1000 = orange + // 10,000+ = bright red + const chipColor = useMemo(() => { + if (customColor) return customColor; + + // Use log scale for better distribution across a wide range of values + const logValue = Math.log10(Math.max(1, value)); + // Using our modified getYellowToRedColor which now transitions from yellow (255,206,0) to deep red (180,0,0) + return getYellowToRedColor(logValue, 0, 15); + }, [value, customColor]); + + // Calculate a darker version of the chip color for the shadow + const shadowColor = useMemo(() => { + // Get RGB values from the chipColor + const rgbMatch = /rgb\((?\d+),\s*(?\d+),\s*(?\d+)\)/.exec( + chipColor + ); + if (rgbMatch?.groups) { + const r = Math.max(0, parseInt(rgbMatch.groups.red, 10) - 80); // Reduce red by 80 + const g = Math.max(0, parseInt(rgbMatch.groups.green, 10) - 60); // Reduce green by 60 + const b = Math.max(0, parseInt(rgbMatch.groups.blue, 10)); // Keep blue as is + return `rgb(${r}, ${g}, ${b})`; + } + return 'rgb(144, 102, 0)'; // Default shadow color + }, [chipColor]); + + // Get formatted value text + const formattedValue = formatCompactNumber(value); + + // Generate box shadow based on disabled state + const boxShadowStyle = isSelected + ? `${shadowColor} 0px 0.125rem 0px 0px, rgba(255, 255, 255) 0px 0.065rem 0px 0.2rem` + : `${shadowColor} 0px 0.125rem 0px 0px`; + + return ( + + {value / 100} + + + +
+ } + > +
{ + if (!disabled) { + onClick?.(); + } + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + if (!disabled) { + onClick?.(); + } + } + }} + role="button" + style={{ + backgroundColor: chipColor, + width: `${size * 4}px`, + height: `${size * 4}px`, + boxShadow: boxShadowStyle, + }} + tabIndex={0} + > + + {formattedValue} + +
+ + ); +} + +export default Chip; diff --git a/apps/frontend/src/features/games/roulette/components/ChipCarousel.tsx b/apps/frontend/src/features/games/roulette/components/ChipCarousel.tsx new file mode 100644 index 0000000..7dec209 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/ChipCarousel.tsx @@ -0,0 +1,88 @@ +import { useRef, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Label } from '@/components/ui/label'; +import useRouletteStore from '../store/rouletteStore'; +import ScrollNextButton from './ScrollNextButton'; +import ScrollPrevButton from './ScrollPrevButton'; +import Chip from './Chip'; + +function ChipCarousel(): JSX.Element { + const scrollContainerRef = useRef(null); + const queryClient = useQueryClient(); + const balance = queryClient.getQueryData(['balance']) || 0; + + const { betAmount, selectedChip, setSelectedChip } = useRouletteStore(); + const scrollLeft = (): void => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollBy({ + left: -100, + behavior: 'smooth', + }); + } + }; + + const scrollRight = (): void => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollBy({ + left: 100, + behavior: 'smooth', + }); + } + }; + + useEffect(() => { + if (selectedChip && balance && betAmount + selectedChip > balance * 100) { + if (selectedChip < 1) { + setSelectedChip(null); + } else { + setSelectedChip(selectedChip / 10); + } + } + }, [selectedChip, betAmount, balance, setSelectedChip]); + + return ( +
+ {balance ? ( + + ) : null} +
+ +
+
+ {Array.from({ + length: 10, + }).map((_, index) => { + const chipValue = 10 ** index; + const isDisabled = balance * 100 - betAmount < chipValue; + + return ( + { + setSelectedChip(chipValue); + }} + size={8} + value={chipValue} + /> + ); + })} +
+
+ + +
+
+ ); +} + +export default ChipCarousel; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteResultBreakdown.tsx b/apps/frontend/src/features/games/roulette/components/RouletteResultBreakdown.tsx new file mode 100644 index 0000000..b2f6865 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteResultBreakdown.tsx @@ -0,0 +1,164 @@ +import { Fragment, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { HashLoader } from 'react-spinners'; +import { getGeneratedFloats, byteGenerator } from '@/lib/crypto'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +// Simple function to +// generate a stable unique ID without using array indices +const generateUniqueId = ( + prefix: string, + ...parts: (string | number)[] +): string => { + return `${prefix}-${parts.join('-')}`; +}; + +interface RouletteResultBreakdownProps { + nonce?: number; + serverSeed?: string; + clientSeed?: string; +} + +function RouletteResultBreakdown({ + nonce, + serverSeed, + clientSeed, +}: RouletteResultBreakdownProps): JSX.Element { + const { data: hmacArray = [] } = useQuery({ + queryKey: ['hmacBuffer', serverSeed, clientSeed, nonce], + queryFn: async () => { + const bytes = await byteGenerator( + serverSeed ?? '', + `${clientSeed}:${nonce}`, + 1 + ); + return bytes; + }, + }); + + const { data: outcome } = useQuery({ + queryKey: ['result-roulette', serverSeed, clientSeed, nonce], + queryFn: async () => { + const result = await getGeneratedFloats({ + count: 1, + seed: serverSeed ?? '', + message: `${clientSeed}:${nonce}`, + }); + return result[0]; + }, + }); + + const selectedBytes = hmacArray.slice(0, 4); + + // Create unique identifiers for each byte in the hmacArray + const hmacByteIds = useMemo(() => { + return hmacArray.map((byte, idx) => ({ + byte, + id: generateUniqueId('byte', byte, idx, Date.now()), + })); + }, [hmacArray]); + + // Create unique identifiers for selected bytes + const selectedByteIds = useMemo(() => { + return selectedBytes.map((byte, idx) => ({ + byte, + id: generateUniqueId('selected-byte', byte, idx, Date.now()), + })); + }, [selectedBytes]); + + if (!serverSeed || !clientSeed || !outcome) { + return ; + } + + const finalOutcome = Math.floor(outcome * 37); + + return ( +
+
+ +

{finalOutcome}

+
+
+ +

+ {`HMAC_SHA256(${serverSeed}, ${clientSeed}:${nonce}:0)`} +

+
+ {hmacByteIds.map(({ byte, id }, index) => ( +
+ {byte.toString(16).padStart(2, '0')} + {byte} +
+ ))} +
+
+
+ +
+
+
{`(${selectedBytes.join(', ')}) -> [0, ..., 36] = ${finalOutcome}`}
+ {selectedByteIds.map(({ byte, id }, index) => ( + + + {index > 0 ? '+' : ''} + + + {(byte / 256 ** (index + 1)) + .toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + {`(${Array((3 - (byte.toString().length % 3)) % 3) + .fill('0') + .join('')}${byte} / (256 ^ ${index + 1}))`} + +
+ ))} + = + + {outcome + ?.toFixed(12) + .split('') + .map((char, charIndex) => ( +
+ {char} +
+ ))} +
+ + (× 37) + + = + + {String((outcome * 37).toFixed(12)).split('.')[0]} + + .{String((outcome * 37).toFixed(12)).split('.')[1]} + + +
+
+
+
+ ); +} + +export default RouletteResultBreakdown; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomBets.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomBets.tsx new file mode 100644 index 0000000..b5d8661 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomBets.tsx @@ -0,0 +1,47 @@ +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/validations.js'; +import BottomNumberBets from './BottomNumberBets'; +import BottomColorBets from './BottomColorBets'; + +export const bottomBets = [ + { + action: RouletteBetTypes.LOW, + label: '1 to 18', + }, + { + action: RouletteBetTypes.EVEN, + label: 'Even', + }, + { + action: RouletteBetTypes.RED, + label: null, + }, + { + action: RouletteBetTypes.BLACK, + label: null, + }, + { + action: RouletteBetTypes.ODD, + label: 'Odd', + }, + { + action: RouletteBetTypes.HIGH, + label: '19 to 36', + }, +]; + +function BottomBets(): JSX.Element { + return ( + <> + {bottomBets.map(({ action, label }) => { + if (typeof label === 'string') { + return ( + + ); + } + return ; + })} + + ); +} + +export default BottomBets; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomColorBets.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomColorBets.tsx new file mode 100644 index 0000000..0cfe80c --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomColorBets.tsx @@ -0,0 +1,108 @@ +import { sum } from 'lodash'; +import { + redNumbers, + blackNumbers, +} from '@repo/common/game-utils/roulette/constants.js'; +import { useMemo } from 'react'; +import { motion } from 'motion/react'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/validations.js'; +import { cn } from '@/lib/utils'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { + useWinningNumber, + useBetKey, +} from '../../store/rouletteStoreSelectors'; +import { useRouletteContext } from '../../context/RouletteContext'; + +function BottomColorBets({ + action, + className, +}: { + action: RouletteBetTypes; + className?: string; +}): JSX.Element { + const { setHoverId } = useRouletteBoardHoverStore(); + + const betId = action; + const { bets, addBet, isRouletteWheelStopped } = useRouletteStore(); + + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + + const { isPreview } = useRouletteContext(); + + const winningNumber = useWinningNumber(); + const betKey = useBetKey(); + const isWinning = useMemo(() => { + if (!winningNumber || !isRouletteWheelStopped) return false; + switch (action) { + case RouletteBetTypes.RED: + return redNumbers.includes(winningNumber.toString()); + case RouletteBetTypes.BLACK: + return blackNumbers.includes(winningNumber.toString()); + default: + return false; + } + }, [action, isRouletteWheelStopped, winningNumber]); + + return ( + { + e.stopPropagation(); + if (!isPreview) { + addBet(betId); + } + }} + onKeyDown={event => { + return event; + }} + onMouseEnter={() => { + setHoverId(action); + }} + onMouseLeave={() => { + setHoverId(null); + }} + role="button" + tabIndex={0} + transition={ + isWinning + ? { + duration: 1, + repeat: Infinity, + } + : { + duration: 0, + repeat: 0, + } + } + > + {isBet ? ( +
+ +
+ ) : null} +
+ ); +} + +export default BottomColorBets; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomNumberBets.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomNumberBets.tsx new file mode 100644 index 0000000..592f8bc --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/BottomNumberBets.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import sum from 'lodash/sum'; +import { motion } from 'motion/react'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/validations.js'; +import { cn } from '@/lib/utils'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { + useWinningNumber, + useBetKey, +} from '../../store/rouletteStoreSelectors'; +import { useRouletteContext } from '../../context/RouletteContext'; + +function BottomNumberBets({ + action, + label, + className, +}: { + action: string; + label: string; + className?: string; +}): JSX.Element { + const { setHoverId } = useRouletteBoardHoverStore(); + const betId = action; + const { bets, addBet, isRouletteWheelStopped } = useRouletteStore(); + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + const winningNumber = useWinningNumber(); + const betKey = useBetKey(); + const { isPreview } = useRouletteContext(); + const isWinning = useMemo(() => { + if (!winningNumber || !isRouletteWheelStopped || winningNumber === '0') + return false; + switch (action as RouletteBetTypes) { + case RouletteBetTypes.LOW: + return Number(winningNumber) >= 1 && Number(winningNumber) <= 18; + case RouletteBetTypes.HIGH: + return Number(winningNumber) >= 19 && Number(winningNumber) <= 36; + case RouletteBetTypes.EVEN: + return Number(winningNumber) % 2 === 0; + case RouletteBetTypes.ODD: + return Number(winningNumber) % 2 !== 0; + default: + return false; + } + }, [action, isRouletteWheelStopped, winningNumber]); + + return ( + { + e.stopPropagation(); + if (!isPreview) { + addBet(betId); + } + }} + onKeyDown={event => { + return event; + }} + onMouseEnter={() => { + setHoverId(action); + }} + onMouseLeave={() => { + setHoverId(null); + }} + role="button" + tabIndex={0} + transition={ + isWinning + ? { + duration: 1, + repeat: Infinity, + } + : { + duration: 0, + repeat: 0, + } + } + > + + {label} + + {isBet ? ( +
+ +
+ ) : null} +
+ ); +} + +export default BottomNumberBets; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/ColumnBet.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/ColumnBet.tsx new file mode 100644 index 0000000..746574d --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/ColumnBet.tsx @@ -0,0 +1,96 @@ +import { sum } from 'lodash'; +import { useMemo } from 'react'; +import { motion } from 'motion/react'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/validations.js'; +import { cn } from '@/lib/utils'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { + useWinningNumber, + useBetKey, +} from '../../store/rouletteStoreSelectors'; +import { useRouletteContext } from '../../context/RouletteContext'; + +function ColumnBet({ + column, + className, +}: { + column: number; + className?: string; +}): JSX.Element { + const { setHoverId } = useRouletteBoardHoverStore(); + + const betId = `${RouletteBetTypes.COLUMN}-${column}`; + const { bets, addBet, isRouletteWheelStopped } = useRouletteStore(); + + const { isPreview } = useRouletteContext(); + + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + + const winningNumber = useWinningNumber(); + const betKey = useBetKey(); + const isWinning = useMemo(() => { + if (!isRouletteWheelStopped) return false; + return ( + winningNumber && + winningNumber !== '0' && + Number(winningNumber) % 3 === column % 3 + ); + }, [column, isRouletteWheelStopped, winningNumber]); + + return ( + { + e.stopPropagation(); + if (!isPreview) { + addBet(betId); + } + }} + onKeyDown={event => { + return event; + }} + onMouseEnter={() => { + setHoverId(betId); + }} + onMouseLeave={() => { + setHoverId(null); + }} + role="button" + tabIndex={0} + transition={ + isWinning + ? { + duration: 1, + repeat: Infinity, + } + : { + duration: 0, + repeat: 0, + } + } + > + 2:1 + {isBet ? ( +
+ +
+ ) : null} +
+ ); +} + +export default ColumnBet; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/DozenBet.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/DozenBet.tsx new file mode 100644 index 0000000..592a8b2 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/DozenBet.tsx @@ -0,0 +1,98 @@ +import { sum } from 'lodash'; +import { motion } from 'motion/react'; +import { useMemo } from 'react'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/validations.js'; +import { cn } from '@/lib/utils'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { + useWinningNumber, + useBetKey, +} from '../../store/rouletteStoreSelectors'; +import { useRouletteContext } from '../../context/RouletteContext'; + +function DozenBet({ + dozen, + className, +}: { + dozen: number; + className?: string; +}): JSX.Element { + const { setHoverId } = useRouletteBoardHoverStore(); + + const { isPreview } = useRouletteContext(); + + const betId = `${RouletteBetTypes.DOZEN}-${dozen}`; + const { bets, addBet, isRouletteWheelStopped } = useRouletteStore(); + + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + + const winningNumber = useWinningNumber(); + const betKey = useBetKey(); + const isWinning = useMemo(() => { + if (!isRouletteWheelStopped) return false; + return ( + winningNumber && + Number(winningNumber) > (dozen - 1) * 12 && + Number(winningNumber) <= dozen * 12 + ); + }, [dozen, isRouletteWheelStopped, winningNumber]); + + return ( + { + e.stopPropagation(); + if (!isPreview) { + addBet(betId); + } + }} + onKeyDown={event => { + return event; + }} + onMouseEnter={() => { + setHoverId(betId); + }} + onMouseLeave={() => { + setHoverId(null); + }} + role="button" + tabIndex={0} + transition={ + isWinning + ? { + duration: 1, + repeat: Infinity, + } + : { + duration: 0, + repeat: 0, + } + } + > + + {12 * (dozen - 1) + 1} to {12 * (dozen - 1) + 12} + + {isBet ? ( +
+ +
+ ) : null} +
+ ); +} + +export default DozenBet; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/DroppableArea.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/DroppableArea.tsx new file mode 100644 index 0000000..bf690c3 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/DroppableArea.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import { sum } from 'lodash'; +import { getBetTypeSelectionId } from '../../utils/helpers'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import { useRouletteContext } from '../../context/RouletteContext'; + +interface PositionValues { + top: string | number; + left: string | number; + transform?: string; +} + +const POSITIONS: Record = { + TL: { top: '0%', left: '0%', transform: 'translate(-50%, -50%)' }, + TC: { top: '0%', left: '50%', transform: 'translate(-50%, -50%)' }, + TR: { + top: '0%', + left: 'calc(100% + 2px)', + transform: 'translate(-50%, -50%)', + }, + CR: { + top: '50%', + left: 'calc(100% + 2px)', + transform: 'translate(-50%, -50%)', + }, + BC: { top: '100%', left: '50%', transform: 'translate(-50%, -50%)' }, + BR: { + top: 'calc(100% + 2px)', + left: 'calc(100% + 2px)', + transform: 'translate(-50%, -50%)', + }, + + // Custom Positions for 0-1, 0-2, 0-3 in Roulette with enlarged hit areas + '0-1': { + top: `83.33%`, + left: 'calc(100% + 2px)', + transform: 'translate(-50%, -50%)', + }, + '0-3': { + top: `16.66%`, + left: 'calc(100% + 2px)', + transform: 'translate(-50%, -50%)', + }, +}; + +function DroppableArea({ + position, + reference, + betTypeData, + width, + height, +}: { + position: keyof typeof POSITIONS; + reference: React.RefObject; + betTypeData: { + betType: string; + selection: number | number[] | null; + }; + width?: string; + height?: string; +}): JSX.Element { + const betId = `${betTypeData.betType}-${getBetTypeSelectionId( + betTypeData.selection || null + )}`; + + const { setHoverId } = useRouletteBoardHoverStore(); + + const { isPreview } = useRouletteContext(); + + const { bets, addBet } = useRouletteStore(); + + const [style, setStyle] = useState({}); + + useEffect(() => { + if (reference.current) { + const rect = reference.current.getBoundingClientRect(); + + const { top, left, transform } = POSITIONS[position]; + + setStyle({ + position: 'absolute', + top: typeof top === 'number' ? rect.top + top : top, + left: typeof left === 'number' ? rect.left + left : left, + transform: transform || undefined, + width: width || '12px', + height: height || '12px', + zIndex: 10, + }); + } + }, [position, reference, width, height]); + + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + + return ( +
{ + e.stopPropagation(); + if (isPreview) return; + addBet(betId); + }} + onKeyDown={event => { + return event; + }} + onMouseEnter={() => { + setHoverId(betId); + }} + onMouseLeave={() => { + setHoverId(null); + }} + role="button" + style={style} + tabIndex={0} + > + {/* Use a transparent div with the full size for the hit area */} +
+ {isBet ? ( +
+ +
+ ) : null} +
+
+ ); +} + +export default DroppableArea; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/NumberBet.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/NumberBet.tsx new file mode 100644 index 0000000..c69d677 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/NumberBet.tsx @@ -0,0 +1,185 @@ +import { redNumbers } from '@repo/common/game-utils/roulette/constants.js'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/index.js'; +import { useRef } from 'react'; +import { sum } from 'lodash'; +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import { getIsNumberHover } from '../../utils/hover'; +import { + shouldRenderBottom, + shouldRenderCornerBet, + shouldRenderRight, + shouldRenderSixLineBet, + shouldRenderTop, +} from '../../utils/shouldRender'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { + useWinningNumber, + useBetKey, +} from '../../store/rouletteStoreSelectors'; +import { useRouletteContext } from '../../context/RouletteContext'; +import DroppableArea from './DroppableArea'; + +function NumberBet({ + number, + className, +}: { + number: number; + className?: string; +}): JSX.Element { + const { hoverId } = useRouletteBoardHoverStore(); + const winningNumber = useWinningNumber(); + const betKey = useBetKey(); + const { isPreview } = useRouletteContext(); + const referenceDiv = useRef(null); + + const isRedNumber = redNumbers.includes(number.toString()); + const isNumberHover = !isPreview && getIsNumberHover({ number, hoverId }); + + const betId = `${RouletteBetTypes.STRAIGHT}-${number}`; + + const { bets, addBet, isRouletteWheelStopped } = useRouletteStore(); + + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + + return ( + { + e.stopPropagation(); + if (!isPreview) { + addBet(betId); + } + }} + onKeyDown={event => { + return event; + }} + ref={el => { + referenceDiv.current = el; + }} + role="button" + tabIndex={0} + transition={ + isRouletteWheelStopped && Number(winningNumber) === number + ? { + duration: 1, + repeat: Infinity, + } + : { + duration: 0, + repeat: 0, + } + } + > + {number} + {isBet ? ( +
+ +
+ ) : null} + + {shouldRenderCornerBet(number) && ( + + )} + {number === 1 && ( + + )} + {number === 2 && ( + + )} + {shouldRenderTop(number) && ( + + )} + {shouldRenderRight(number) && ( + + )} + {shouldRenderBottom(number) && ( + + )} + {shouldRenderSixLineBet(number) && ( + + )} +
+ ); +} + +export default NumberBet; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/ZeroBet.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/ZeroBet.tsx new file mode 100644 index 0000000..d835c51 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/ZeroBet.tsx @@ -0,0 +1,119 @@ +import { useRef } from 'react'; +import { sum } from 'lodash'; +import { motion } from 'motion/react'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/validations.js'; +import { cn } from '@/lib/utils'; +import { useRouletteBoardHoverStore } from '../../store/rouletteBoardHoverStore'; +import { getIsNumberHover } from '../../utils/hover'; +import useRouletteStore from '../../store/rouletteStore'; +import Chip from '../Chip'; +import { + useWinningNumber, + useBetKey, +} from '../../store/rouletteStoreSelectors'; +import { useRouletteContext } from '../../context/RouletteContext'; +import DroppableArea from './DroppableArea'; + +function ZeroBet({ className }: { className?: string }): JSX.Element { + const { hoverId } = useRouletteBoardHoverStore(); + const referenceDiv = useRef(null); + + const { isPreview } = useRouletteContext(); + + const isNumberHover = !isPreview && getIsNumberHover({ number: 0, hoverId }); + const winningNumber = useWinningNumber(); + + const betId = `${RouletteBetTypes.STRAIGHT}-0`; + const { bets, addBet, isRouletteWheelStopped } = useRouletteStore(); + + const isBet = bets[betId] && bets[betId].length > 0; + const betAmount = sum(bets[betId]); + + const isWinning = isRouletteWheelStopped && Number(winningNumber) === 0; + const betKey = useBetKey(); + return ( + { + e.stopPropagation(); + if (!isPreview) { + addBet(betId); + } + }} + onKeyDown={event => { + return event; + }} + ref={el => { + referenceDiv.current = el; + }} + role="button" + tabIndex={0} + transition={ + isWinning + ? { + duration: 1, + repeat: Infinity, + } + : { + duration: 0, + repeat: 0, + } + } + > + 0 + {isBet ? ( +
+ +
+ ) : null} + + + + +
+ ); +} + +export default ZeroBet; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteTable/index.tsx b/apps/frontend/src/features/games/roulette/components/RouletteTable/index.tsx new file mode 100644 index 0000000..ae2b85a --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteTable/index.tsx @@ -0,0 +1,120 @@ +import ZeroBet from './ZeroBet'; +import NumberBet from './NumberBet'; +import ColumnBet from './ColumnBet'; +import DozenBet from './DozenBet'; +import BottomBets, { bottomBets } from './BottomBets'; +import BottomNumberBets from './BottomNumberBets'; +import BottomColorBets from './BottomColorBets'; +import { cn } from '@/lib/utils'; + +function RouletteTable(): JSX.Element { + return ( + <> +
+
+ + {Array.from({ length: 12 }, (_, index) => index).map(colNum => ( +
+ {Array.from({ length: 3 }, (_, index) => index + 1).map( + rowNum => { + return ( + + ); + } + )} +
+ ))} +
+ + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+ {bottomBets.map(({ action, label }, index) => { + return ( +
+ {typeof label === 'string' ? ( + + ) : ( + + )} +
+ ); + })} +
+ +
+
+ +
+
+ +
+ {Array.from({ length: 12 }, (_, index) => index).map( + (colNum, index) => ( +
+ {Array.from({ length: 3 }, (_, index) => index + 1).map( + rowNum => { + return ( + + ); + } + )} +
+ ) + )} +
+
+ + + +
+
+ + ); +} + +export default RouletteTable; diff --git a/apps/frontend/src/features/games/roulette/components/RouletteWheel.tsx b/apps/frontend/src/features/games/roulette/components/RouletteWheel.tsx new file mode 100644 index 0000000..bb64ef8 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/RouletteWheel.tsx @@ -0,0 +1,180 @@ +import { motion, useAnimation } from 'motion/react'; +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { cn } from '@/lib/utils'; +import useRouletteStore from '../store/rouletteStore'; + +const normalizeAngle = (angle: number): number => ((angle % 360) + 360) % 360; + +const rouletteWheelNumbers = [ + '0', + '32', + '15', + '19', + '4', + '21', + '2', + '25', + '17', + '34', + '6', + '27', + '13', + '36', + '11', + '30', + '8', + '23', + '10', + '5', + '24', + '16', + '33', + '1', + '20', + '14', + '31', + '9', + '22', + '18', + '29', + '7', + '28', + '12', + '35', + '3', + '26', +]; + +function RouletteWheel({ + isSpinning, + winningNumber, + isPreview = false, +}: { + isSpinning: boolean; + winningNumber: string | null; + isPreview?: boolean; +}): JSX.Element { + const selectedNumber = winningNumber || '0'; + const radius = 202; + const angleStep = 360 / 37; + const textRadius = radius - 25; // Move text inward + const textOffset = 15; // Small shift left/right inside the segment + const selectedIndex = rouletteWheelNumbers.indexOf(selectedNumber); + const rotationOffset = + selectedIndex !== -1 ? -(selectedIndex * angleStep) + 90 : 0; + + const rotationAngle = normalizeAngle(rotationOffset); + + const controls = useAnimation(); + const { setIsRouletteWheelStopped } = useRouletteStore(); + useEffect(() => { + if (isSpinning && !isPreview) + void controls.start({ + rotate: [rotationAngle, rotationAngle + 360], + transition: { repeat: Infinity, duration: 1, ease: 'linear' }, + }); + }, [controls, isSpinning, rotationAngle, isPreview]); + + useEffect(() => { + if (winningNumber && !isPreview) { + controls.stop(); + void controls + .start({ + rotate: [rotationAngle, rotationAngle + 360], + transition: { duration: 1, ease: 'easeOut' }, + }) + .then(() => { + setIsRouletteWheelStopped(true); + }); + } + }, [ + controls, + rotationAngle, + setIsRouletteWheelStopped, + winningNumber, + isPreview, + ]); + + return ( +
+ {/* The fixed arrow that doesn't rotate with the wheel */} +
+ Roulette Wheel Arrow +
+ + + {/* Inner Circle (Clipping Center) */} +
+ +
+ + {/* Segments */} + {Array.from({ length: 37 }, (_, i) => { + const rotation = i * angleStep; + + return ( +
+ ); + })} + + {/* Numbers */} + {rouletteWheelNumbers.map((number, i) => { + const angle = (i - 0.5) * angleStep; // Offset to center of segment + const radian = (angle * Math.PI) / 180; + const x = textRadius * Math.cos(radian); + const y = textRadius * Math.sin(radian); + + // Shift perpendicular to the spoke direction + const shiftX = textOffset * Math.cos((angle + 90) * (Math.PI / 180)); + const shiftY = textOffset * Math.sin((angle + 90) * (Math.PI / 180)); + + return ( +
+ {number} +
+ ); + })} + +
+ ); +} + +export default RouletteWheel; diff --git a/apps/frontend/src/features/games/roulette/components/ScrollNextButton.tsx b/apps/frontend/src/features/games/roulette/components/ScrollNextButton.tsx new file mode 100644 index 0000000..74a5e0f --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/ScrollNextButton.tsx @@ -0,0 +1,19 @@ +import { ChevronRightIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ScrollNextButtonProps { + onClick?: () => void; +} + +function ScrollNextButton({ onClick }: ScrollNextButtonProps): JSX.Element { + return ( + + ); +} + +export default ScrollNextButton; diff --git a/apps/frontend/src/features/games/roulette/components/ScrollPrevButton.tsx b/apps/frontend/src/features/games/roulette/components/ScrollPrevButton.tsx new file mode 100644 index 0000000..94abe51 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/components/ScrollPrevButton.tsx @@ -0,0 +1,19 @@ +import { ChevronLeftIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ScrollPrevButtonProps { + onClick?: () => void; +} + +function ScrollPrevButton({ onClick }: ScrollPrevButtonProps): JSX.Element { + return ( + + ); +} + +export default ScrollPrevButton; diff --git a/apps/frontend/src/features/games/roulette/context/RouletteContext.tsx b/apps/frontend/src/features/games/roulette/context/RouletteContext.tsx new file mode 100644 index 0000000..204a031 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/context/RouletteContext.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +interface RouletteContextType { + isPreview: boolean; +} + +const RouletteContext = createContext( + undefined +); + +export function RouletteProvider({ + isPreview, + children, +}: { + children: ReactNode; + isPreview: boolean; +}): JSX.Element { + return ( + + {children} + + ); +} + +export function useRouletteContext(): RouletteContextType { + const context = useContext(RouletteContext); + if (context === undefined) { + return { isPreview: false }; + } + return context; +} diff --git a/apps/frontend/src/features/games/roulette/index.tsx b/apps/frontend/src/features/games/roulette/index.tsx new file mode 100644 index 0000000..8f79a4c --- /dev/null +++ b/apps/frontend/src/features/games/roulette/index.tsx @@ -0,0 +1,160 @@ +import { sum } from 'lodash'; +import type { RouletteBet } from '@repo/common/game-utils/roulette/validations.js'; +import { validateBets } from '@repo/common/game-utils/roulette/validations.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { BadgeDollarSignIcon, RefreshCcwIcon, Undo2Icon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { placeBet } from '@/api/games/roulette'; +import { getBalance } from '@/api/balance'; +import { Games } from '@/const/games'; +import GameSettingsBar from '../common/components/game-settings'; +import BettingControls from './components/BettingControls'; +import RouletteTable from './components/RouletteTable'; +import RouletteWheel from './components/RouletteWheel'; +import useRouletteStore from './store/rouletteStore'; +import { parseBetId } from './utils/helpers'; +import { RouletteProvider } from './context/RouletteContext'; +import { useWinningNumber } from './store/rouletteStoreSelectors'; +import { cn } from '@/lib/utils'; + +export function Roulette({ + isPreview = false, +}: { + isPreview?: boolean; +}): JSX.Element { + const { + undoBet, + clearBets, + bets, + betHistory, + betAmount, + setLatestResult, + latestResult, + isRouletteWheelStopped, + setIsRouletteWheelStopped, + } = useRouletteStore(); + const queryClient = useQueryClient(); + const { data: balance = 0 } = useQuery({ + queryKey: ['balance'], + queryFn: getBalance, + refetchInterval: 120000, + // Refetch every 2 minutes + }); + + const { mutate, isPending: isSpinning } = useMutation({ + mutationFn: (rouletteBets: RouletteBet[]) => placeBet(rouletteBets), + onSuccess: ({ data }) => { + setLatestResult(data); + queryClient.setQueryData(['balance'], data.balance); + return data; + }, + onError: error => { + return error; + }, + }); + + const winningNumber = useWinningNumber(); + + const onBet = (): void => { + if (latestResult) { + setLatestResult(null); + return; + } + const rouletteBet = Object.entries(bets).map(([betId, amounts]) => { + const { betType, selection } = parseBetId(betId); + const totalBetAmount = sum(amounts) / 100; + return { + betType, + ...(selection === null ? {} : { selection }), + amount: totalBetAmount, + } as RouletteBet; + }); + + const rouletteBets = validateBets(rouletteBet); + setIsRouletteWheelStopped(false); + mutate(rouletteBets); + }; + + return ( +
+ +
+ : null} + isDisabled={ + latestResult + ? false + : betHistory.length === 0 || + isSpinning || + !isRouletteWheelStopped || + balance * 100 < betAmount + } + isPending={isSpinning ? true : !isRouletteWheelStopped} + onBet={onBet} + /> +
+
+ +
+
+ {isRouletteWheelStopped ? winningNumber : null} +
+
+
+ {latestResult?.payout && isRouletteWheelStopped ? ( +
+ {latestResult.payoutMultiplier.toFixed(2)}x + | + {latestResult.payout} + +
+ ) : null} +
+
+ {isRouletteWheelStopped ? winningNumber : null} +
+
+ +
+
+
+ {latestResult?.payoutMultiplier.toFixed(2)}x + | + {latestResult?.payout} + +
+
+
+
+ + +
+
+
+
+ {/* */} +
+ ); +} diff --git a/apps/frontend/src/features/games/roulette/store/rouletteBoardHoverStore.ts b/apps/frontend/src/features/games/roulette/store/rouletteBoardHoverStore.ts new file mode 100644 index 0000000..aec123f --- /dev/null +++ b/apps/frontend/src/features/games/roulette/store/rouletteBoardHoverStore.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +interface RouletteBoardHoverStore { + hoverId: string | null; + setHoverId: (hoverId: string | null) => void; +} + +export const useRouletteBoardHoverStore = create( + set => ({ + hoverId: null, + setHoverId: hoverId => { + set({ hoverId }); + }, + }) +); diff --git a/apps/frontend/src/features/games/roulette/store/rouletteStore.ts b/apps/frontend/src/features/games/roulette/store/rouletteStore.ts new file mode 100644 index 0000000..7e80be2 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/store/rouletteStore.ts @@ -0,0 +1,122 @@ +import type { RoulettePlaceBetResponse } from '@repo/common/game-utils/roulette/types.js'; +import { create } from 'zustand'; + +interface RouletteStoreActions { + setSelectedChip: (chip: number | null) => void; + setBetAmount: (betAmount: number) => void; + addBetHistory: (betHistory: string) => void; + clearBets: () => void; + undoBet: () => void; + addBet: (betId: string) => void; + updateBetAmount: (betAmount: number) => void; + multiplyBets: (multiplier: number) => void; + setLatestResult: (result: RoulettePlaceBetResponse | null) => void; + setIsRouletteWheelStopped: (isStopped: boolean) => void; +} + +interface RouletteStoreState { + selectedChip: number | null; + betAmount: number; + betHistory: string[]; + bets: Record; + latestResult: RoulettePlaceBetResponse | null; + isRouletteWheelStopped: boolean; +} + +const initialState: RouletteStoreState = { + betAmount: 0, + betHistory: [], + bets: {}, + selectedChip: 1, + latestResult: null, + isRouletteWheelStopped: true, +}; + +const useRouletteStore = create( + set => ({ + ...initialState, + setBetAmount: betAmount => { + set({ betAmount }); + }, + updateBetAmount: betAmount => { + set(state => ({ betAmount: state.betAmount + betAmount })); + }, + setSelectedChip: chip => { + if (chip && chip < 1) { + set({ selectedChip: null }); + } + set({ selectedChip: chip }); + }, + addBetHistory: betHistory => { + set(state => ({ betHistory: [...state.betHistory, betHistory] })); + }, + clearBets: () => { + set(initialState); + }, + multiplyBets: (multiplier: number) => { + set(state => ({ + bets: Object.fromEntries( + Object.entries(state.bets).map(([key, betAmountArray]) => [ + key, + betAmountArray?.map(betAmount => betAmount * multiplier), + ]) + ), + })); + }, + undoBet: () => { + set(state => { + if (state.betHistory.length === 0) return state; + const lastBetId = state.betHistory[state.betHistory.length - 1]; + + const lastChipOnBoardBetAmountArray = state.bets[lastBetId]; + + const lastChipOnBoardBetAmount = + lastChipOnBoardBetAmountArray?.[ + lastChipOnBoardBetAmountArray.length - 1 + ] || 0; + + return { + betHistory: state.betHistory.slice(0, -1), + bets: { + ...state.bets, + [lastBetId]: state.bets[lastBetId]?.slice(0, -1) || [], + }, + betAmount: state.betAmount - lastChipOnBoardBetAmount, + }; + }); + }, + addBet: betId => { + set(state => { + if (!state.selectedChip) { + return state; + } + + if (state.selectedChip < 1) { + return { selectedChip: null }; + } + + const updatedBets = { ...state.bets }; + + if (!updatedBets[betId]) { + updatedBets[betId] = []; + } + + updatedBets[betId] = [...updatedBets[betId], state.selectedChip]; + + return { + bets: updatedBets, + betAmount: state.betAmount + state.selectedChip, + betHistory: [...state.betHistory, betId], + }; + }); + }, + setLatestResult: result => { + set({ latestResult: result }); + }, + setIsRouletteWheelStopped: isStopped => { + set({ isRouletteWheelStopped: isStopped }); + }, + }) +); + +export default useRouletteStore; diff --git a/apps/frontend/src/features/games/roulette/store/rouletteStoreSelectors.ts b/apps/frontend/src/features/games/roulette/store/rouletteStoreSelectors.ts new file mode 100644 index 0000000..bb33953 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/store/rouletteStoreSelectors.ts @@ -0,0 +1,10 @@ +import useRouletteStore from './rouletteStore'; + +export const useWinningNumber = (): string | null => + useRouletteStore(state => { + const winningNumber = state.latestResult?.state.winningNumber; + return winningNumber || null; + }); + +export const useBetKey = (): string | null => + useRouletteStore(state => state.latestResult?.id ?? null); diff --git a/apps/frontend/src/features/games/roulette/utils/helpers.ts b/apps/frontend/src/features/games/roulette/utils/helpers.ts new file mode 100644 index 0000000..fa47e33 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/utils/helpers.ts @@ -0,0 +1,41 @@ +// This function converts a bet selection (number, array, or null) to a string ID format +const getBetTypeSelectionId = ( + betSelection: number | number[] | null +): string => { + if (typeof betSelection === 'number') { + return betSelection.toString(); + } + if (Array.isArray(betSelection)) { + return betSelection.join('-'); + } + return 'null'; +}; + +// This function parses a bet ID back into its component parts (betType and selection) +const parseBetId = ( + betId: string +): { + betType: string; + selection: number | number[] | null; +} => { + const [betType, ...selectionParts] = betId.split('-'); + + if (selectionParts.length === 0 || selectionParts[0] === 'null') { + return { betType, selection: null }; + } + + if (selectionParts.length === 1) { + return { + betType, + selection: parseInt(selectionParts[0], 10), + }; + } + + // If we have multiple parts, it's an array of numbers + return { + betType, + selection: selectionParts.map(part => parseInt(part, 10)), + }; +}; + +export { getBetTypeSelectionId, parseBetId }; diff --git a/apps/frontend/src/features/games/roulette/utils/hover.ts b/apps/frontend/src/features/games/roulette/utils/hover.ts new file mode 100644 index 0000000..3d15a12 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/utils/hover.ts @@ -0,0 +1,83 @@ +import { + blackNumbers, + redNumbers, +} from '@repo/common/game-utils/roulette/constants.js'; +import { RouletteBetTypes } from '@repo/common/game-utils/roulette/index.js'; + +const getIsColumnHover = ({ + number, + hoverId, +}: { + number: number; + hoverId: string; +}): boolean => { + const column = parseInt(hoverId.split('-')[1]); + return number !== 0 && number % 3 === column % 3; +}; + +const getIsDozenHover = ({ + number, + hoverId, +}: { + number: number; + hoverId: string; +}): boolean => { + const dozen = parseInt(hoverId.split('-')[1]); + return number > (dozen - 1) * 12 && number <= dozen * 12; +}; + +const checkIsChipHover = ({ + number, + hoverId, +}: { + number: number; + hoverId: string; +}): boolean => { + const [betType, ...selection] = hoverId.split('-'); + + switch (betType as RouletteBetTypes) { + case RouletteBetTypes.STRAIGHT: + case RouletteBetTypes.SPLIT: + case RouletteBetTypes.SIXLINE: + case RouletteBetTypes.CORNER: + case RouletteBetTypes.STREET: + return selection.includes(number.toString()); + default: + return false; + } +}; + +const getIsNumberHover = ({ + number, + hoverId, +}: { + number: number; + hoverId: string | null; +}): boolean => { + if (hoverId === null) return false; + + if (hoverId.startsWith('column-')) { + return getIsColumnHover({ number, hoverId }); + } + if (hoverId.startsWith('dozen-')) { + return getIsDozenHover({ number, hoverId }); + } + switch (hoverId as RouletteBetTypes) { + case RouletteBetTypes.LOW: + return number >= 1 && number <= 18; + case RouletteBetTypes.HIGH: + return number >= 19 && number <= 36; + case RouletteBetTypes.EVEN: + return number % 2 === 0; + case RouletteBetTypes.ODD: + return number % 2 !== 0; + case RouletteBetTypes.RED: + return redNumbers.includes(number.toString()); + case RouletteBetTypes.BLACK: + return blackNumbers.includes(number.toString()); + default: + return checkIsChipHover({ number, hoverId }); + } +}; + +export { getIsNumberHover }; diff --git a/apps/frontend/src/features/games/roulette/utils/shouldRender.ts b/apps/frontend/src/features/games/roulette/utils/shouldRender.ts new file mode 100644 index 0000000..43e2208 --- /dev/null +++ b/apps/frontend/src/features/games/roulette/utils/shouldRender.ts @@ -0,0 +1,30 @@ +const noTopRender = [3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36]; +const noRightRender = [34, 35, 36]; +const bottomRender = [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34]; +const noCornerBetRender = [...noTopRender, 34, 35]; +const sixLinesBetRender = bottomRender.slice(0, bottomRender.length - 1); +const topRightDobuleStreetRender = noTopRender.slice(0, noTopRender.length - 1); + +const shouldRenderCornerBet = (number: number): boolean => + !noCornerBetRender.includes(number); + +export const shouldRenderTop = (currentNumber: number): boolean => + !noTopRender.includes(currentNumber); + +export const shouldRenderRight = (currentNumber: number): boolean => + !noRightRender.includes(currentNumber); + +export const shouldRenderBottom = (currentNumber: number): boolean => + bottomRender.includes(currentNumber); + +export const shouldRenderSixLineBet = (currentNumber: number): boolean => + sixLinesBetRender.includes(currentNumber); + +export const shouldRenderTopStreet = (currentNumber: number): boolean => + noTopRender.includes(currentNumber); + +export const shouldRenderTopRightDoubleStreet = ( + currentNumber: number +): boolean => topRightDobuleStreetRender.includes(currentNumber); + +export { shouldRenderCornerBet }; diff --git a/apps/frontend/src/features/global-modals/bet-modal/game-viz.tsx b/apps/frontend/src/features/global-modals/bet-modal/game-viz.tsx new file mode 100644 index 0000000..c025851 --- /dev/null +++ b/apps/frontend/src/features/global-modals/bet-modal/game-viz.tsx @@ -0,0 +1,27 @@ +import { Games } from '@/const/games'; +import { BetData } from '@repo/common/types'; +import DiceBetViz from '@/features/games/dice/components/DiceBetViz'; +import MinesBetViz from '@/features/games/mines/components/MinesBetViz'; +import KenoBetViz from '@/features/games/keno/components/KenoBetViz'; +import BlackjackBetViz from '@/features/games/blackjack/components/BlackjackBetVIz'; + +const GameViz = ({ bet }: { bet: BetData }) => { + switch (bet.game as Games) { + case Games.DICE: { + return ; + } + case Games.MINES: { + return ; + } + case Games.KENO: { + return ; + } + case Games.BLACKJACK: { + return ; + } + default: + return null; + } +}; + +export default GameViz; diff --git a/apps/frontend/src/features/global-modals/bet-modal/index.tsx b/apps/frontend/src/features/global-modals/bet-modal/index.tsx new file mode 100644 index 0000000..5d38a5e --- /dev/null +++ b/apps/frontend/src/features/global-modals/bet-modal/index.tsx @@ -0,0 +1,196 @@ +import React, { useEffect } from 'react'; +import { GLOBAL_MODAL } from '../types'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import BetsIcon from '@/assets/icons/bets'; +import { cn } from '@/lib/utils'; +import { Link, useRouter } from '@tanstack/react-router'; +import { Route } from '@/routes/__root'; +import BetIcon from '@/assets/icons/bet'; +import { useQuery } from '@tanstack/react-query'; +import { fetchAllBets, fetchBetById } from '@/api/games/bets'; +import { Game, GAME_VALUES_MAPPING, Games } from '@/const/games'; +import chunk from 'lodash/chunk'; +import CopyIcon from '@/assets/icons/copy'; +import { Tooltip } from '@/components/ui/tooltip'; +import CommonTooltip from '@/components/ui/common-tooltip'; +import { format, isValid } from 'date-fns'; +import { BadgeDollarSign } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import ProvablyFair from './provably-fair'; +import GameViz from './game-viz'; + +const BetModal = ({ iid, modal }: { iid?: number; modal?: GLOBAL_MODAL }) => { + const [open, setOpen] = React.useState(!!(iid && modal === GLOBAL_MODAL.BET)); + const [showCopiedText, setShowCopiedText] = React.useState(false); + const router = useRouter(); + const currentSearchParams = Route.useSearch(); + + const { data } = useQuery({ + queryKey: [modal, iid], + queryFn: () => { + return fetchBetById(iid!); + }, + placeholderData: prev => prev, + enabled: open, + }); + + const { bet } = data?.data || {}; + + const handleRemoveParams = () => { + // Navigate to the current route's path but with the specified search params removed. + // Remove 'iid' and 'modal' from the search params object before navigating + router.navigate({ + search: () => { + return { + iid: undefined, + modal: undefined, + } as never; + }, + }); + }; + + const handleCopyLink = () => { + navigator.clipboard.writeText(window.location.href); + setShowCopiedText(true); + setTimeout(() => setShowCopiedText(false), 2000); + }; + + useEffect(() => { + setOpen(!!(iid && modal === GLOBAL_MODAL.BET)); + }, [iid, modal]); + + const onOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + handleRemoveParams(); + } + }; + + return ( + + + + + + Bet + + + {bet ? ( +
+
+
+ + {GAME_VALUES_MAPPING[bet.game as Game].label} + +
+ + ID:{' '} + {chunk(bet.betId, 3) + .map(idChunk => idChunk.join('')) + .join(',')} + + + +
+ +
+
+
+
+
+
+ Placed by{' '} + + {bet?.user?.name} + +
+
+ on {format(new Date(bet?.date), 'M/d/yyyy')} at{' '} + {format(new Date(bet?.date), 'h:mm a')} +
+
+
+ SimCasino Logo +
+
+
+ Bet + + {bet?.betAmount.toFixed(2)} + + +
+
+
+ Multiplier + + {bet?.payoutMultiplier.toFixed(2)}x + +
+
+
+ Payout + 0, + })} + > + {(bet?.payout - bet?.betAmount).toFixed(2)} + + +
+
+ + + + +
+ {bet.provablyFairState && ( + + + + Provable Fairness + + + + + + + )} +
+ ) : null} + +
+ ); +}; + +export default BetModal; diff --git a/apps/frontend/src/features/global-modals/bet-modal/provably-fair.tsx b/apps/frontend/src/features/global-modals/bet-modal/provably-fair.tsx new file mode 100644 index 0000000..4f3623e --- /dev/null +++ b/apps/frontend/src/features/global-modals/bet-modal/provably-fair.tsx @@ -0,0 +1,138 @@ +import CopyIcon from '@/assets/icons/copy'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { Button } from '@/components/ui/button'; +import CommonTooltip from '@/components/ui/common-tooltip'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { Link } from '@tanstack/react-router'; +import React from 'react'; +import { GLOBAL_MODAL } from '../types'; +import { Games } from '@/const/games'; +import { BetData } from '@repo/common/types'; + +const ProvablyFair = ({ bet }: { bet: BetData }) => { + const { provablyFairState, isMyBet, game, betNonce } = bet; + const { serverSeed, hashedServerSeed, clientSeed } = provablyFairState || {}; + const activeSeedInputs = [ + { + key: 'serverSeed', + label: 'Server Seed', + value: serverSeed, + isCopyActive: true, + disabled: !serverSeed, + }, + { + key: 'hashedServerSeed', + label: 'Server Seed (Hashed)', + value: hashedServerSeed, + isCopyActive: true, + }, + { + key: 'clientSeed', + label: 'Client Seed', + value: clientSeed, + isCopyActive: true, + }, + { + key: 'nonce', + label: 'Nonce', + value: betNonce, + isCopyActive: true, + }, + ]; + const [showCopiedText, setShowCopiedText] = React.useState<{ + [key: string]: boolean; + }>({}); + + return ( +
+ {activeSeedInputs.map(input => ( +
+ +
+
+ +
+ {input.isCopyActive && !input.disabled ? ( + + + + ) : null} +
+
+ ))} +
+ {!serverSeed ? ( + isMyBet ? ( + + Rotate your seed pair in order to verify this bet + + ) : ( + Server seed needs to be changed to verify bet... + ) + ) : ( + + Verify + + )} + + What is Provable Fairness? + +
+
+ ); +}; + +export default ProvablyFair; diff --git a/apps/frontend/src/features/global-modals/index.tsx b/apps/frontend/src/features/global-modals/index.tsx new file mode 100644 index 0000000..becbe19 --- /dev/null +++ b/apps/frontend/src/features/global-modals/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { GLOBAL_MODAL } from './types'; +import BetModal from './bet-modal'; +import { fairnessModalSearchSchema, Route } from '@/routes/__root'; +import { betModalSearchSchema } from '../../routes/__root'; +import { z } from 'zod'; +import { FairnessModal } from '../games/common/components/fairness-modal'; + +const GlobalModals = () => { + const search = Route.useSearch(); + return ( + <> + )?.iid} + modal={search?.modal} + /> + )?.game} + show={search?.modal === GLOBAL_MODAL.FAIRNESS} + tab={ + (search as z.infer)?.tab || 'seeds' + } + /> + + ); +}; + +export default GlobalModals; diff --git a/apps/frontend/src/features/global-modals/types/index.ts b/apps/frontend/src/features/global-modals/types/index.ts new file mode 100644 index 0000000..bc06f76 --- /dev/null +++ b/apps/frontend/src/features/global-modals/types/index.ts @@ -0,0 +1,4 @@ +export enum GLOBAL_MODAL { + BET = 'bet', + FAIRNESS = 'fairness', +} diff --git a/apps/frontend/src/features/home/index.tsx b/apps/frontend/src/features/home/index.tsx new file mode 100644 index 0000000..555f76b --- /dev/null +++ b/apps/frontend/src/features/home/index.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { SearchIcon } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { Games } from '@/const/games'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import MyBetsTable from '../my-bets/my-bets-table'; +import AllBetsTable from '../all-bets/all-bets-table'; +import { getAuthState } from '../auth/store/authStore'; +import { cn } from '@/lib/utils'; + +enum TabsEnum { + MyBets = 'myBets', + AllBets = 'allBets', +} + +function Home(): JSX.Element { + const [searchText, setSearchText] = useState(''); + const { user } = getAuthState(); + const [activeTab, setActiveTab] = useState( + user ? TabsEnum.MyBets : TabsEnum.AllBets + ); + return ( +
+ } + onChange={(e: React.ChangeEvent) => { + setSearchText(e.target.value); + }} + placeholder="Search your game" + /> + +
+ {Object.values(Games) + .filter(game => game.toLowerCase().includes(searchText.toLowerCase())) + .map(game => ( + + {game} + + ))} +
+ { + setActiveTab(value as TabsEnum); + }} + > + + {user && My Bets} + + All Bets + + + + {user && ( + + + + )} + + + + +
+ ); +} + +export default Home; diff --git a/apps/frontend/src/features/landing/core-features.tsx b/apps/frontend/src/features/landing/core-features.tsx new file mode 100644 index 0000000..da63393 --- /dev/null +++ b/apps/frontend/src/features/landing/core-features.tsx @@ -0,0 +1,116 @@ +import { Brain, Bot, Scale, Zap } from 'lucide-react'; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +const features = [ + { + title: 'Strategy Playground', + description: + 'Develop and refine your manual betting strategies without any financial risk. Test different approaches and learn what works.', + icon: Brain, + status: 'Available', + highlight: true, + }, + { + title: 'Automated Testing', + description: + 'Get ready to automate your strategies with our upcoming bot system. Set it and forget it while testing your algorithms.', + icon: Bot, + status: 'Coming Soon', + highlight: false, + }, + { + title: 'Provably Fair System', + description: + 'Our games are provably fair. Every outcome is verifiable on the blockchain for complete transparency and trust.', + icon: Scale, + status: 'Available', + highlight: false, + }, + { + title: 'Real-Time Analytics', + description: + 'Track your performance with detailed statistics and insights. Understand your win rates and optimize your strategies.', + icon: Zap, + status: 'Coming Soon', + highlight: false, + }, +]; + +export function CoreFeatures(): JSX.Element { + return ( +
+
+
+

+ Core{' '} + + Features + +

+

+ Everything you need to master crypto casino strategies in a + risk-free environment. +

+
+ +
+ {features.map(feature => { + const IconComponent = feature.icon; + return ( + + +
+
+
+ +
+
+ + {feature.title} + +
+
+ + {feature.status} + +
+ + {feature.description} + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/frontend/src/features/landing/featured-games.tsx b/apps/frontend/src/features/landing/featured-games.tsx new file mode 100644 index 0000000..42dd630 --- /dev/null +++ b/apps/frontend/src/features/landing/featured-games.tsx @@ -0,0 +1,129 @@ +import { Bomb, Spade, Dice6 } from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +// import minesGame from '@/assets/mines-game.jpg'; +// import blackjackGame from '@/assets/blackjack-game.jpg'; +// import diceGame from '@/assets/dice-game.jpg'; + +const games = [ + { + title: 'Mines', + description: + 'Click on tiles to reveal gems and multiply your bet, but beware of the hidden mines!', + // image: minesGame, + icon: Bomb, + difficulty: 'Medium', + minBet: '0.01', + maxMultiplier: '1,000,000x', + }, + { + title: 'Blackjack', + description: + 'The classic card game. Try to beat the dealer by getting as close to 21 as you can.', + // image: blackjackGame, + icon: Spade, + difficulty: 'Hard', + minBet: '0.01', + maxMultiplier: '2.5x', + }, + { + title: 'Dice', + description: + 'Predict if the dice roll will be over or under your chosen number. Simple yet strategic.', + // image: diceGame, + icon: Dice6, + difficulty: 'Easy', + minBet: '0.01', + maxMultiplier: '9900x', + }, +]; + +export function FeaturedGames(): JSX.Element { + return ( +
+
+
+

+ Featured{' '} + + Games + +

+

+ Practice on real casino games with fake money. Perfect your + strategies risk-free. +

+
+ +
+ {games.map(game => { + const IconComponent = game.icon; + return ( + +
+ {/* {`${game.title} */} +
+ + {game.difficulty} + +
+ + +
+
+ +
+ {game.title} +
+ + {game.description} + +
+ + +
+
+
Min Bet
+
{game.minBet} coins
+
+
+
Max Win
+
+ {game.maxMultiplier} +
+
+
+ + +
+ + ); + })} +
+
+
+ ); +} diff --git a/apps/frontend/src/features/landing/footer.tsx b/apps/frontend/src/features/landing/footer.tsx new file mode 100644 index 0000000..e5315bd --- /dev/null +++ b/apps/frontend/src/features/landing/footer.tsx @@ -0,0 +1,144 @@ +import { Twitter, Github, MessageCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; + +export function Footer(): JSX.Element { + return ( + + ); +} diff --git a/apps/frontend/src/features/landing/hero-section.tsx b/apps/frontend/src/features/landing/hero-section.tsx new file mode 100644 index 0000000..c082008 --- /dev/null +++ b/apps/frontend/src/features/landing/hero-section.tsx @@ -0,0 +1,156 @@ +import { ArrowRight, Play } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import { Button } from '@/components/ui/button'; + +export function HeroSection(): JSX.Element { + const navigate = useNavigate(); + return ( +
+ {/* Background gradient overlay */} +
+ + {/* Floating decorative elements */} +
+ {/* Top left floating card */} +
+
+
🎲
+
Risk Free
+
+
+ + {/* Top right floating card */} +
+
+
📈
+
Strategy Test
+
+
+ + {/* Bottom left floating card */} +
+
+
+
Instant Play
+
+
+ + {/* Bottom right floating card */} +
+
+
🎯
+
Zero Risk
+
+
+ + {/* Floating particles */} +
+
+
+
+ +
+ {/* Trust indicators above headline */} +
+
+
+ 100% Safe +
+
+
+ Provably Fair +
+
+
+ + Stake-like UI + +
+ {/*
+
+ + No Registration + +
*/} +
+ +
+

+ Test Your{' '} + + Strategy + + . +
+ Not Your Wallet. +

+ +

+ A risk-free platform to test your crypto casino strategies on real + games with fake money. +

+
+ +
+ +
+ + {/* Enhanced stats section */} +
+
+
+
+ 10K+ +
+
Strategies Tested
+
+
+
+ 24/7 +
+
Always Available
+
+
+
+ 100% +
+
Provably Fair
+
+
+ + {/* Quick feature highlights */} +
+
+
+ Unlimited Practice +
+
+
+ Real Game Mechanics +
+
+
+ Instant Access +
+
+
+
+ + {/* Enhanced animated glow effects */} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/features/landing/index.tsx b/apps/frontend/src/features/landing/index.tsx new file mode 100644 index 0000000..1f3e8f5 --- /dev/null +++ b/apps/frontend/src/features/landing/index.tsx @@ -0,0 +1,19 @@ +import { HeroSection } from './hero-section'; +import { FeaturedGames } from './featured-games'; +import { CoreFeatures } from './core-features'; +import { TransparencyFeed } from './transparency-feed'; +import { Footer } from './footer'; + +function Landing(): JSX.Element { + return ( +
+ + + + +
+
+ ); +} + +export default Landing; diff --git a/apps/frontend/src/features/landing/transparency-feed.tsx b/apps/frontend/src/features/landing/transparency-feed.tsx new file mode 100644 index 0000000..f19aea9 --- /dev/null +++ b/apps/frontend/src/features/landing/transparency-feed.tsx @@ -0,0 +1,226 @@ +import { TrendingUp, TrendingDown, Activity } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; + +interface GameResult { + id: string; + game: string; + result: string; + outcome: 'win' | 'loss'; + multiplier?: string; + timestamp: string; +} + +const generateMockResult = (): GameResult => { + const games = ['Crash', 'Plinko', 'Dice']; + const game = games[Math.floor(Math.random() * games.length)]; + const isWin = Math.random() > 0.52; // Slightly house-favored + + let result = ''; + let multiplier = ''; + + if (game === 'Crash') { + const crashPoint = (Math.random() * 10 + 1).toFixed(2); + result = `${crashPoint}x`; + multiplier = isWin + ? `+${(Math.random() * 5 + 1).toFixed(2)}x` + : `-${(Math.random() * 2 + 0.5).toFixed(2)}x`; + } else if (game === 'Plinko') { + const slot = Math.floor(Math.random() * 16); + result = `Slot ${slot}`; + multiplier = isWin + ? `+${(Math.random() * 3 + 0.5).toFixed(2)}x` + : `-${(Number(Math.random()) + 0.5).toFixed(2)}x`; + } else { + const roll = Math.floor(Math.random() * 100); + result = `${roll}`; + multiplier = isWin + ? `+${(Math.random() * 2 + 0.5).toFixed(2)}x` + : `-${(Number(Math.random()) + 0.5).toFixed(2)}x`; + } + + return { + id: Math.random().toString(36).substr(2, 9), + game, + result, + outcome: isWin ? 'win' : 'loss', + multiplier, + timestamp: new Date().toLocaleTimeString(), + }; +}; + +export function TransparencyFeed(): JSX.Element { + const [results, setResults] = useState([]); + + useEffect(() => { + // Initialize with some results + const initialResults = Array.from({ length: 8 }, generateMockResult); + setResults(initialResults); + + // Add new results every 3-6 seconds + const interval = setInterval( + () => { + const newResult = generateMockResult(); + setResults(prev => [newResult, ...prev.slice(0, 7)]); + }, + Math.random() * 3000 + 3000 + ); + + return () => { + clearInterval(interval); + }; + }, []); + + const winCount = results.filter(r => r.outcome === 'win').length; + const lossCount = results.filter(r => r.outcome === 'loss').length; + const winRate = + results.length > 0 ? ((winCount / results.length) * 100).toFixed(1) : '0'; + + return ( +
+
+
+

+ Real Outcomes. Unfiltered{' '} + + Transparency + + . +

+

+ Watch live game results as they happen. Every outcome is recorded + and verifiable. +

+
+ +
+ {/* Stats Cards */} +
+ + + + Platform Stats + + + +
+
+ + Player Wins +
+ + {winCount} + +
+
+
+ + House Wins +
+ + {lossCount} + +
+
+
+ + Win Rate +
+ + {winRate}% + +
+
+
+
+ + {/* Live Feed */} + + +
+
+ +
+ Live Game Results + + + Real-time feed of recent game outcomes + +
+
+ + + + + + Game + Result + Outcome + Time + + + + {results.map(result => ( + + + {result.game} + + + {result.result} + + +
+ {result.outcome === 'win' ? ( + + ) : ( + + )} + + {result.multiplier} + +
+
+ + {result.timestamp} + +
+ ))} +
+
+
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/features/my-bets/columns.tsx b/apps/frontend/src/features/my-bets/columns.tsx new file mode 100644 index 0000000..58e11f6 --- /dev/null +++ b/apps/frontend/src/features/my-bets/columns.tsx @@ -0,0 +1,153 @@ +import type { PaginatedBetData } from '@repo/common/types'; +import type { ColumnDef } from '@tanstack/react-table'; +import { BadgeDollarSign, ListChecksIcon } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; +import { format, isValid } from 'date-fns'; +import { GAME_VALUES_MAPPING } from '@/const/games'; +import { cn } from '@/lib/utils'; +import { BetsTableColumns } from '@/const/tables'; +import { ViewportType } from '@/common/hooks/useViewportType'; +import { GLOBAL_MODAL } from '../global-modals/types'; + +export const columns = ( + viewport: ViewportType +): ColumnDef[] => { + return [ + { + header: 'Game', + id: BetsTableColumns.GAME, + accessorKey: BetsTableColumns.GAME, + cell: ({ row }) => { + const game = + GAME_VALUES_MAPPING[ + row.original.game as keyof typeof GAME_VALUES_MAPPING + ]; + + if (viewport !== ViewportType.Desktop) { + return ( + +
+ {'icon' in game && ( + + )} + {game.label} +
+ + ); + } + + return ( + +
+ {'icon' in game && ( + + )} + {game.label} +
+ + ); + }, + }, + { + header: 'Bet ID', + accessorKey: BetsTableColumns.BET_ID, + id: BetsTableColumns.BET_ID, + cell: ({ row }) => { + return ( + +
+ + {row.original.betId} +
+ + ); + }, + }, + + { + header: 'Date', + accessorKey: BetsTableColumns.DATE, + id: BetsTableColumns.DATE, + cell: ({ row }) => { + // Format the date using date-fns + const date = new Date(row.original.date); + const formattedDate = isValid(date) + ? format(date, 'h:mm a M/d/yyyy') + : String(row.original.date); + + return ( +
{formattedDate}
+ ); + }, + meta: { + alignment: 'right', + }, + }, + { + header: 'Bet Amount', + accessorKey: BetsTableColumns.BET_AMOUNT, + id: BetsTableColumns.BET_AMOUNT, + cell: ({ row }) => { + return ( +
+ {row.original.betAmount.toFixed(2)}{' '} + +
+ ); + }, + meta: { + alignment: 'right', + }, + }, + { + header: 'Multiplier', + accessorKey: BetsTableColumns.PAYOUT_MULTIPLIER, + id: BetsTableColumns.PAYOUT_MULTIPLIER, + cell: ({ row }) => { + return ( +
+ {row.original.payoutMultiplier.toFixed(2)}x +
+ ); + }, + meta: { + alignment: 'right', + }, + }, + { + header: 'Payout', + accessorKey: BetsTableColumns.PAYOUT, + id: BetsTableColumns.PAYOUT, + cell: ({ row }) => { + return ( +
0, + } + )} + > + {row.original.payout.toFixed(2)}{' '} + +
+ ); + }, + meta: { + alignment: 'right', + }, + }, + ]; +}; diff --git a/apps/frontend/src/features/my-bets/index.tsx b/apps/frontend/src/features/my-bets/index.tsx new file mode 100644 index 0000000..34e3743 --- /dev/null +++ b/apps/frontend/src/features/my-bets/index.tsx @@ -0,0 +1,44 @@ +import { NotepadTextIcon } from 'lucide-react'; +import MyBetsTable from './my-bets-table'; +import BetsIcon from '@/assets/icons/bets'; + +const provablyFairRoutes = [ + { + label: 'Overview', + path: '/provably-fair', + }, + { + label: 'Implementation', + path: '/provably-fair/implementation', + }, + { + label: 'Conversions', + path: '/provably-fair/conversions', + }, + { + label: 'Game Events', + path: '/provably-fair/game-events', + }, + { + label: 'Unhash Server Seed', + path: '/provably-fair/unhash-server-seed', + }, + { + label: 'Calculation', + path: '/provably-fair/calculation', + }, +]; + +function MyBets(): JSX.Element { + return ( +
+
+ +

My Bets

+
+ +
+ ); +} + +export default MyBets; diff --git a/apps/frontend/src/features/my-bets/my-bets-table.tsx b/apps/frontend/src/features/my-bets/my-bets-table.tsx new file mode 100644 index 0000000..72dff1f --- /dev/null +++ b/apps/frontend/src/features/my-bets/my-bets-table.tsx @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { fetchUserBetHistory } from '@/api/user'; +import { CommonDataTable } from '@/components/ui/common-data-table'; +import { columns } from './columns'; +import { useViewportType, ViewportType } from '@/common/hooks/useViewportType'; +import { BetsTableColumns, betsTableViewportWiseColumns } from '@/const/tables'; +import BetsIcon from '@/assets/icons/bets'; +import { useLocation, useRouter } from '@tanstack/react-router'; + +function MyBetsTable(): JSX.Element { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const viewport = useViewportType(); + const router = useRouter(); + + const location = useLocation(); + + const tableColumns = columns(viewport); + + const { data } = useQuery({ + queryKey: ['my-bets', pagination], + queryFn: () => + fetchUserBetHistory({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }), + placeholderData: prev => prev, + }); + + const usedColumns = betsTableViewportWiseColumns[viewport] + ? tableColumns.filter(col => + betsTableViewportWiseColumns[viewport]?.includes( + col.id as BetsTableColumns + ) + ) + : tableColumns; + + return ( + , + title: 'No bets yet', + description: + "You haven't placed any bets yet. Start playing some games to see your betting history here.", + ...(location.pathname !== '/casino/home' && { + action: { + label: 'Explore Games', + onClick: () => { + router.navigate({ to: '/casino/home' }); + }, + }, + }), + }} + /> + ); +} + +export default MyBetsTable; diff --git a/apps/frontend/src/features/privacy-policy/index.tsx b/apps/frontend/src/features/privacy-policy/index.tsx new file mode 100644 index 0000000..7a46179 --- /dev/null +++ b/apps/frontend/src/features/privacy-policy/index.tsx @@ -0,0 +1,128 @@ +import Documentation from '@/components/documentation'; +import BulletPoints from '@/components/documentation/bullet'; +import Heading from '@/components/documentation/heading'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; +import { Header } from '@/components/Header'; + +function PrivacyPolicy(): JSX.Element { + return ( + <> +
+
+ +
+ Privacy Policy + Last updated: September 27, 2025 +
+
+ 1. Introduction + + Welcome to SimCasino.club ("we", "us", "our"). We are committed to + protecting your privacy. This Privacy Policy explains how we + collect, use, disclose, and safeguard your information when you + use our service. + +
+
+ 2. Information We Collect + + We may collect information about you in a variety of ways. The + information we may collect on the Service includes: + + + Personal Data: Personally identifiable + information, such as your name, email address, and profile + picture, that you voluntarily give to us when you register + with the Service (e.g., via Google OAuth). + , + + Derivative Data: Information our servers + automatically collect when you access the Service, such as + your IP address, your browser type, your operating system, + your access times, and the pages you have viewed directly + before and after accessing the Service. + , + + Usage Data: We collect data related to your + gameplay, such as bets placed, games played, and strategies + tested. This data is used to provide you with statistics and + is not shared with third parties. + , + ]} + /> +
+
+ 3. Use of Your Information + + Having accurate information about you permits us to provide you + with a smooth, efficient, and customized experience. Specifically, + we may use information collected about you via the Service to: + + Create and manage your account, + + Compile anonymous statistical data and analysis for use + internally. + , + + Monitor and analyze usage and trends to improve your + experience with the Service. + , + + Provide and deliver the products and services you request + , + ]} + /> +
+
+ 4. Disclosure of Your Information + + We do not share, sell, rent or trade your personal information + with any third parties for their commercial purposes. + +
+
+ 5. Security of Your Information + + We use administrative, technical, and physical security measures + to help protect your personal information. While we have taken + reasonable steps to secure the personal information you provide to + us, please be aware that despite our efforts, no security measures + are perfect or impenetrable. + +
+
+ 6. Policy for Children + + We do not knowingly solicit information from or market to children + under the age of 18. If you become aware of any data we have + collected from children under age 18, please contact us using the + contact information provided below. + +
+
+ 7. Changes to This Privacy Policy + + We may update this Privacy Policy from time to time. We will + notify you of any changes by posting the new Privacy Policy on + this page. + +
+
+ 8. Contact Us + + If you have any questions about this Privacy Policy, please + contact us at [your-contact-email@example.com]. + +
+
+
+ + ); +} + +export default PrivacyPolicy; diff --git a/apps/frontend/src/features/provaly-fair/Conversions.tsx b/apps/frontend/src/features/provaly-fair/Conversions.tsx new file mode 100644 index 0000000..da4fb52 --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/Conversions.tsx @@ -0,0 +1,102 @@ +import Documentation from '@/components/documentation'; +import Code from '@/components/documentation/code'; +import Heading from '@/components/documentation/heading'; +import Link from '@/components/documentation/link'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; + +const Conversions = () => { + return ( + +
+ Bytes to Floats + + The output of the Random Number Generator (byteGenerator) function is + a hexadecimal 32-byte hash. As explained under the cursor + implementation, we use 4 bytes of data to generate a single game + result. Each set of 4 bytes are used to generate floats between 0 and + 1 (4 bytes are used instead of one to ensure a higher level of + precision when generating the float.) It is with these generated + floats that we derive the formal output of the provable fair algorithm + before it is translated into game events. + + + {`// Convert the hash output from the rng byteGenerator to floats +function generateFloats ({ serverSeed, clientSeed, nonce, cursor, count }) { + // Random number generator function + const rng = byteGenerator({ serverSeed, clientSeed, nonce, cursor }); + // Declare bytes as empty array + const bytes = []; + + // Populate bytes array with sets of 4 from RNG output + while (bytes.length < count * 4) { + bytes.push(rng.next().value); + } + + // Return bytes as floats using lodash reduce function + return _.chunk(bytes, 4).map(bytesChunk => + bytesChunk.reduce((result, value, i) => { + const divider = 256 ** (i + 1); + const partialResult = value / divider; + return result + partialResult; + }, 0) + ); +}; +`} + +
+
+ Floats to Game Events + + Where the process of generating random outputs is universal for all + our games, it's at this point in the game outcome generation where a + unique procedure is implemented to determine the translation from + floats to game events. + + + The randomly float generated is multiplied by the possible remaining + outcomes of the particular game being played. For example: In a game + that uses a 52 card deck, this would simply be done by multiplying the + float by 52. The result of this equation is then translated into a + corresponding game event. For games where multiple game events are + required, this process continues through each corresponding 4 bytes in + the result chain that was generated using the described byteGenerator + function. + +
+
+ Shuffle of Game Events + + For games such as Keno, Mines, Pump and Video Poker, where outcomes + cannot be duplicated, we then utilise the{' '} + + algorithm. This procedure influences the conversion process from + floats to game events because each time a game event is translated, + the amount of possible remaining game event possibilities has been + reduced for any remaining steps in the result chain. + + + As an example, in video poker, there is at first 52 cards available in + the complete deck, and therefore the first game event is translated by + multiplying the float by 52. Once this card has been dealt, there is + only 51 remaining cards in the deck, and therefore the second card + translation is done by multiplying the second float generated by 51. + This continues in the same fashion until all the game events required + have been generated. + + + With regards to Mines and Keno, this is simply a matter of + implementing the same process as explained with video poker but + changing that to tiles or locations on the board or grid, ensuring + that each game event generated, hasn’t already been done so beforehand + in the chain of results. + +
+
+ ); +}; + +export default Conversions; diff --git a/apps/frontend/src/features/provaly-fair/GameEvents.tsx b/apps/frontend/src/features/provaly-fair/GameEvents.tsx new file mode 100644 index 0000000..606c233 --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/GameEvents.tsx @@ -0,0 +1,155 @@ +import Documentation from '@/components/documentation'; +import BulletPoints from '@/components/documentation/bullet'; +import Code from '@/components/documentation/code'; +import Heading from '@/components/documentation/heading'; +import Link from '@/components/documentation/link'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; + +const GameEvents = () => { + return ( + +
+ + Game events are translation of the randomly generated floats into a + relatable outcome that is game specific. This includes anything from + the outcome of a dice roll to the order of the cards in a deck, or + even the location of every bomb in a game of mines. + + + Below is a detailed explanation as to how we translate floats into + events for each particular different game on our platform. + +
+
+ Blackjack + + In a standard deck of cards, there are 52 unique possible outcomes. + When it comes to playing Blackjack on our platform, we utilise an + unlimited amount of decks when generating the game event, and + therefore each turn of a card always has the same probability. To + calculate this, we multiply each randomly generated float by 52, and + then translate that result into a particular card, based on the + following index: + + + // Index of 0 to 51 : ♦2 to ♣A
+ const CARDS = [
+

+ ♦2, ♥2, ♠2, ♣2, ♦3, ♥3, ♠3, ♣3, ♦4, ♥4,
+ ♠4, ♣4, ♦5, ♥5, ♠5, ♣5, ♦6, ♥6, ♠6, ♣6, +
+ ♦7, ♥7, ♠7, ♣7, ♦8, ♥8, ♠8, ♣8, ♦9, ♥9, +
♠9, ♣9, ♦10, ♥10, ♠10, ♣10, ♦J, ♥J, ♠J, +
+ ♣J, ♦Q, ♥Q, ♠Q, ♣Q, ♦K, ♥K, ♠K, ♣K, ♦A,
+ ♥A, ♠A, ♣A +

+ ];
+
// Game event translation
+ const card = CARDS[Math.floor(float * 52)]; +
+ + There is a cursor of 13 to generate 52 possible game events for cases + where a large amount of cards are required to be dealt to the player + +
+
+ Dice Roll + + In our version of dice, we cover a possible roll spread of 00.00 to + 100.00, which has a range of 10,001 possible outcomes. The game event + translation is done by multiplying the float by number of possible + outcomes and then dividing by 100 so that the resulting number fits + the constraints of our stated dice range. + + + // Game event translation
+ const roll = (float * 10001) / 100; +
+
+
+ Roulette Roll + + Our Roulette is derived from the European version of the game where + the wheel consists of 37 possible different pockets, ranging from 0 to + 36. The game event is calculated by multiplying the float by 37 and + then translated into a corresponding pocket using the following index: + + + // Index of 0 to 36 +
+ const POCKETS = [ +
+

+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
+ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, +
+ 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, +
30, 31, 32, 33, 34, 35, 36 +

+ ];
+
// Game event translation
+ const pocket = POCKETS[Math.floor(float * 37)]; +
+
+
+ Keno + + Traditional Keno games require the selection of 10 possible game + events in the form of hits on a board. To achieve this, we multiply + each float by the number of possible unique squares that exist. Once a + hit has been placed, it cannot be chosen again, which changes the pool + size of the possible outcomes. This is done by subtracting the size of + possible maximum outcomes by 1 for each iteration of game event result + generated using the corresponding float provided, using the following + index: + + + // Index of 0 to 39 : 1 to 40 +
+ const SQUARES = [ +
+

+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, +
+ 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, +
+ 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, +
+ 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 +

+ ]; +
+
+ // Game event translation +
+ const hit = SQUARES[Math.floor(float * 40)]; +
+ + The fisher-yates shuffle implementation is utilised to prevent + duplicate possible hits being generated. + +
+
+ Mines + + A mine game is generated with 24 separate game events, in the form of + mines on the board. Each float is multiplied by the number of possible + unique tiles still remaining on the board. This is done by subtracting + the number of tiles remaining by 1 for each iteration of game event + result generated using the corresponding float provided. The location + of the mine is plotted using a grid position from left to right, top + to bottom. + + + The fisher-yates shuffle implementation is utilised to prevent + duplicate possible hits being generated. Between 1 and 24 game event + results are used, based on the settings chosen. + +
+
+ ); +}; + +export default GameEvents; diff --git a/apps/frontend/src/features/provaly-fair/Implementation.tsx b/apps/frontend/src/features/provaly-fair/Implementation.tsx new file mode 100644 index 0000000..7c33a25 --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/Implementation.tsx @@ -0,0 +1,153 @@ +import Documentation from '@/components/documentation'; +import BulletPoints from '@/components/documentation/bullet'; +import Code from '@/components/documentation/code'; +import Heading from '@/components/documentation/heading'; +import Link from '@/components/documentation/link'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; + +const Implementation = () => { + return ( + +
+ Random Number Generation + + For each verifiable bet, a client seed, a server seed, a nonce and a + cursor are used as the input parameters for the{' '} + + function. This function utilises the cryptographic hash function{' '} + + to generate bytes which are then used as the foundation for how we + generate provably fair random outcomes on our platform. + + + {`// Random number generation based on following inputs: serverSeed, clientSeed, nonce and cursor \n +function byteGenerator({ serverSeed, clientSeed, nonce, cursor }) { + // Setup cursor variables + let currentRound = Math.floor(cursor / 32); + let currentRoundCursor = cursor; + currentRoundCursor -= currentRound * 32; + + // Generate outputs until cursor requirement fullfilled + while (true) { + // HMAC function used to output provided inputs into bytes + const hmac = createHmac('sha256', serverSeed); + hmac.update(\`\${clientSeed}:\${nonce}:\${currentRound}\`); + const buffer = hmac.digest(); + + // Update cursor for next iteration of loop + while (currentRoundCursor < 32) { + yield Number(buffer[currentRoundCursor]); + currentRoundCursor += 1; + } + currentRoundCursor = 0; + currentRound += 1; + } +}`} + +
+
+ Server Seed + + The server seed is generated by our system as a random 64-character + hex string. You are then provided with an encrypted hash of that + generated server seed before you place any bets. The reason we provide + you with the encrypted form of the server seed is to ensure that the + un-hashed server seed cannot be changed by the casino operator, and + that the player cannot calculate the results beforehand. + + + To reveal the server seed from its hashed version, the seed must be + rotated by the player, which triggers the replacement with a newly + generated one. + + + From this point you are able to verify that the hashed server seed + matches that of the un-hashed server seed. This process can be + verified via our un-hashed server seed function found in the menu + above. + +
+
+ Client Seed + + The client seed belongs to the player and is used to ensure they have + influence on the randomness of the outcomes generated. Without this + component of the algorithm, the server seed alone would have complete + leverage over the outcome of each bet. + + + All players are free to edit and change their client seed regularly to + create a new chain of random upcoming outcomes. This ensures the + player has absolute control over the generation of the result, similar + to cutting the deck at a brick and mortar casino. + + + During registration, a client seed is created for you by your browser, + to ensure your initial experience with the site goes uninterrupted. + Whilst this randomly generated client seed is considered suitable, we + highly recommend that you choose your own, so that your influence is + included in the randomness. + + You can do this via the fairness modal. +
+
+ Cursor (Incremental Number) + + We use 4 bytes of data to generate a single game result, and because + SHA256 is limited to 32 bytes, we utilise this implementation of a + cursor to give us the ability to create more game events without + having to modify our provable fair algorithm. + + + The cursor is only iterated over when the game being played requires + the generation of more than 8 (32 bytes / 4 bytes) possible outcomes. + For example: when we need to use more than 8 cards in a game of + blackjack. + + + The cursor starts as 0 and gets increased by 1 every time the 32 bytes + are returned by the HMAC_SHA256 function. If we don’t require more + than 8 random numbers to be generated for the game events, then the + cursor does not increment as there is no need to generate any + additional possible game outcomes. + + + + Games with more than 1 incremental number: + + + + Keno (2 increments for every game due to 10 possible outcomes) + , + + Mines (3 increments per game for 24 possible bomb locations) + , + + Blackjack (Unlimited to cover required amount of cards) + , + ]} + /> + + + Games with only 1 incremental number (represented as default value + 0): + + + Dice, + Roulette, + ]} + /> +
+
+ ); +}; + +export default Implementation; diff --git a/apps/frontend/src/features/provaly-fair/Overview.tsx b/apps/frontend/src/features/provaly-fair/Overview.tsx new file mode 100644 index 0000000..1cc4cdc --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/Overview.tsx @@ -0,0 +1,57 @@ +import Documentation from '@/components/documentation'; +import BulletPoints from '@/components/documentation/bullet'; +import Code from '@/components/documentation/code'; +import Heading from '@/components/documentation/heading'; +import Link from '@/components/documentation/link'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; + +const Overview = () => { + return ( + +
+ Solving the Trust Issue with Online Gambling + + The underlying concept of provable fairness is that players have the + ability to prove and verify that their results are fair and + unmanipulated. This is achieved through the use of a{' '} + + , along with cryptographic hashing. + + + The commitment scheme is used to ensure that the player has an + influence on all results generated. Cryptographic hashing is used to + ensure that the casino also remains honest to this commitment scheme. + Both concepts combined creates a trust-less environment when gambling + online. + + + This is simplified in the following representation: + + fair result = operators input (hashed) + players input +
+
+ 3rd Party Verification + + All games played on SimCasino can be verified both here and via 3rd + party websites who have also open sourced the verification procedure. + You can find them via a google search, or simply check out some of + these: + + , + ]} + /> +
+
+ ); +}; + +export default Overview; diff --git a/apps/frontend/src/features/provaly-fair/ProvablyFairCalculation.tsx b/apps/frontend/src/features/provaly-fair/ProvablyFairCalculation.tsx new file mode 100644 index 0000000..633cf6d --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/ProvablyFairCalculation.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react'; +import { Games, GAMES_DROPDOWN_OPTIONS, type Game } from '@/const/games'; +import CommonSelect from '@/components/ui/common-select'; +import type { VerificationInputsState } from '../games/common/components/fairness-modal/VerificationInputs'; +import VerificationInputs from '../games/common/components/fairness-modal/VerificationInputs'; +import VerificationResult from '../games/common/components/fairness-modal/VerificationResult'; +import DiceResultBreakdown from '../games/dice/components/DiceResultBreakdown'; +import RouletteResultBreakdown from '../games/roulette/components/RouletteResultBreakdown'; +import MinesResultBreakdown from '../games/mines/components/MinesResultBreakdown'; +import KenoResultBreakdown from '../games/keno/components/KenoResultBreakdown'; +import BlackjackResultBreakdown from '../games/blackjack/components/BlackjackResultBreakdown'; +import { getRouteApi, useSearch } from '@tanstack/react-router'; +const routeApi = getRouteApi('/_public/provably-fair/calculation'); + +function ProvablyFairCalculation(): JSX.Element { + const { game, clientSeed, serverSeed, nonce } = routeApi.useSearch(); + const [outcome, setOutcome] = useState(null); + const [selectedGame, setSelectedGame] = useState( + game || GAMES_DROPDOWN_OPTIONS[0].value + ); + + const [verificationInputs, setVerificationInputs] = + useState({ + clientSeed: clientSeed || '', + serverSeed: serverSeed || '', + nonce: nonce || 1, + }); + + useEffect(() => { + if (game) { + setSelectedGame(game); + } + setVerificationInputs({ + clientSeed: clientSeed || '', + serverSeed: serverSeed || '', + nonce: nonce || 1, + }); + }, [game]); + + const getGameBreakdown = (): JSX.Element => { + switch (selectedGame) { + case Games.DICE: + return ( + + ); + + // case Games.ROULETTE: + // return ( + // + // ); + case Games.MINES: + return ( + + ); + case Games.KENO: + return ( + + ); + case Games.BLACKJACK: + return ( + + ); + default: + return
Unknown game
; + } + }; + return ( +
+
+ { + setSelectedGame(value as Game); + }} + options={GAMES_DROPDOWN_OPTIONS} + value={selectedGame} + /> + +
+
+ +
+
{getGameBreakdown()}
+
+ ); +} + +export default ProvablyFairCalculation; diff --git a/apps/frontend/src/features/provaly-fair/UnhashServerSeed.tsx b/apps/frontend/src/features/provaly-fair/UnhashServerSeed.tsx new file mode 100644 index 0000000..55a82a0 --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/UnhashServerSeed.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { AlertCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import InputWithIcon from '@/common/forms/components/InputWithIcon'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { fetchRevealedServerSeed } from '@/api/user'; + +function UnhashServerSeed(): JSX.Element { + const [hashedServerSeed, setHashedServerSeed] = useState(''); + const [revealedServerSeed, setRevealedServerSeed] = useState( + null + ); + + const { + mutate: revealServerSeed, + isPending: isRevealing, + isError, + error, + } = useMutation({ + mutationFn: async () => { + setRevealedServerSeed(null); + if (!hashedServerSeed || hashedServerSeed.length !== 64) { + throw new Error('Hashed server seed must be 64 characters long'); + } + const apiResponse = await fetchRevealedServerSeed(hashedServerSeed); + if (!apiResponse.data.serverSeed) { + throw new Error('Server seed not found'); + } + return apiResponse; + }, + onSuccess: data => { + setRevealedServerSeed(data.data.serverSeed); + }, + }); + return ( +
+
+ +
+
+ { + setHashedServerSeed(e.target.value); + }} + value={hashedServerSeed} + wrapperClassName={cn( + 'bg-brand-stronger border-brand-weaker shadow-none w-full pr-0 h-8 rounded-r-none', + { + 'border-red-500': isError, + } + )} + /> +
+ + +
+ {isError ? ( +
+ + {error.message} +
+ ) : null} +
+
+ +
+
+ +
+
+
+
+ ); +} + +export default UnhashServerSeed; diff --git a/apps/frontend/src/features/provaly-fair/index.tsx b/apps/frontend/src/features/provaly-fair/index.tsx new file mode 100644 index 0000000..ce7fbfb --- /dev/null +++ b/apps/frontend/src/features/provaly-fair/index.tsx @@ -0,0 +1,121 @@ +import { Link, Outlet, useLocation } from '@tanstack/react-router'; +import { + ChevronDownIcon, + ChevronUp, + ChevronUpIcon, + ScaleIcon, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useState } from 'react'; +import { Header } from '@/components/Header'; + +const provablyFairRoutes = [ + { + label: 'Overview', + path: '/provably-fair', + }, + { + label: 'Implementation', + path: '/provably-fair/implementation', + }, + { + label: 'Conversions', + path: '/provably-fair/conversions', + }, + { + label: 'Game Events', + path: '/provably-fair/game-events', + }, + { + label: 'Unhash Server Seed', + path: '/provably-fair/unhash-server-seed', + }, + { + label: 'Calculation', + path: '/provably-fair/calculation', + }, +]; + +function ProvablyFair(): JSX.Element { + const { pathname } = useLocation(); + + const [dropdownOpen, setDropdownOpen] = useState(false); + + return ( + <> +
+
+
+ +

Provably Fair

+
+
+
+ {provablyFairRoutes.map(route => ( + +

+ {route.label} +

+ + ))} +
+ + + + { + provablyFairRoutes.find(route => route.path === pathname) + ?.label + } + + {dropdownOpen ? ( + + ) : ( + + )} + + + {provablyFairRoutes.map(route => ( + + {route.label} + + ))} + + + +
+ +
+
+
+ + ); +} + +export default ProvablyFair; diff --git a/apps/frontend/src/features/terms-and-conditions/index.tsx b/apps/frontend/src/features/terms-and-conditions/index.tsx new file mode 100644 index 0000000..a8510a3 --- /dev/null +++ b/apps/frontend/src/features/terms-and-conditions/index.tsx @@ -0,0 +1,101 @@ +import Documentation from '@/components/documentation'; +import Heading from '@/components/documentation/heading'; +import Paragraph from '@/components/documentation/paragraph'; +import Section from '@/components/documentation/section'; +import { Header } from '@/components/Header'; + +function TermsAndConditions(): JSX.Element { + return ( + <> +
+
+ +
+ Terms and Conditions + Last updated: September 27, 2025 +
+
+ 1. Introduction + + Welcome to SimCasino.club ("we", "us", "our"). These Terms and + Conditions govern your use of our website and services. By + accessing or using our service, you agree to be bound by these + terms. If you disagree with any part of the terms, you may not + access the service. + +
+
+ 2. Use of Service + + SimCasino.club provides a simulated gambling environment for + entertainment and educational purposes only. No real money is + involved in any of the games. You are provided with "play money" + that has no real-world value. + + + You must be at least 18 years old or of legal age for gambling in + your jurisdiction to use our service. + +
+
+ 3. User Accounts + + When you create an account with us, you must provide information + that is accurate, complete, and current at all times. Failure to + do so constitutes a breach of the Terms, which may result in + immediate termination of your account on our service. + + + You are responsible for safeguarding the password that you use to + access the service and for any activities or actions under your + password. + +
+
+ 4. Intellectual Property + + The service and its original content, features, and functionality + are and will remain the exclusive property of SimCasino.club and + its licensors. + +
+
+ 5. Limitation of Liability + + In no event shall SimCasino.club, nor its directors, employees, + partners, agents, suppliers, or affiliates, be liable for any + indirect, incidental, special, consequential or punitive damages, + including without limitation, loss of profits, data, use, + goodwill, or other intangible losses, resulting from your access + to or use of or inability to access or use the service. + +
+
+ 6. Governing Law + + These Terms shall be governed and construed in accordance with the + laws, without regard to its conflict of law provisions. + +
+
+ 7. Changes to Terms + + We reserve the right, at our sole discretion, to modify or replace + these Terms at any time. We will provide at least 30 days' notice + prior to any new terms taking effect. + +
+
+ 8. Contact Us + + If you have any questions about these Terms, please contact us at + support@simcasino.club. + +
+
+
+ + ); +} + +export default TermsAndConditions; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css new file mode 100644 index 0000000..1e6b82c --- /dev/null +++ b/apps/frontend/src/index.css @@ -0,0 +1,159 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + /* Hide number input spinners */ + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type='number'] { + -moz-appearance: textfield; + } + + @layer base { + /* WebKit Scrollbar (Chrome, Edge, Safari) */ + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: transparent; + border-radius: 10px; + } + + *::-webkit-scrollbar-thumb { + background: #7c3aed; + border-radius: 10px; + } + + *::-webkit-scrollbar-thumb:hover { + background: #a78bfa; + } + + *::-webkit-scrollbar-button { + width: 0; + height: 0; + display: none; + } + + /* Firefox Scrollbar */ + * { + scrollbar-width: thin; + scrollbar-color: #2f4553 transparent; + } + + .no-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE & Edge */ + } + + .no-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari */ + } + } + + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 15%; + --muted-foreground: 210 15% 65%; + --accent: 270 100% 65%; + --accent-purple: 270 100% 65%; + --accent-foreground: 210 20% 95%; + --accent-glow: 270 100% 75%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --primary-glow: 220 100% 70%; /* Lighter blue for glow */ + --accent-glow: 270 100% 75%; + + /* Success/Win - Cyan */ + --success: 180 100% 50%; + --success-foreground: 210 100% 6%; + + /* Gradients */ + --gradient-primary: linear-gradient( + 135deg, + hsl(220 100% 60%), + hsl(270 100% 65%) + ); + --gradient-subtle: linear-gradient( + 180deg, + hsl(210 60% 12%), + hsl(210 100% 8%) + ); + --gradient-glow: linear-gradient( + 135deg, + hsl(220 100% 70% / 0.2), + hsl(270 100% 75% / 0.2) + ); + + /* Shadows */ + --shadow-glow: 0 0 30px hsl(220 100% 60% / 0.3); + --shadow-card: 0 10px 30px -10px hsl(210 100% 4% / 0.5); + + /* Animations */ + --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --transition-glow: + box-shadow 0.3s ease-in-out, border-color 0.3s ease-in-out; + } + + .dark { + --background: 206, 53%, 12%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 206, 36%, 16%; + --secondary-light: 199, 35%, 19%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --input-disabled: 204, 29%, 25%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground font-custom; + } +} diff --git a/apps/frontend/src/lib/crypto.ts b/apps/frontend/src/lib/crypto.ts new file mode 100644 index 0000000..8a7da45 --- /dev/null +++ b/apps/frontend/src/lib/crypto.ts @@ -0,0 +1,124 @@ +import { range } from 'lodash'; +import chunk from 'lodash/chunk'; + +export const generateRandomString = (length = 10): string => { + const array = new Uint8Array(length); + return btoa( + String.fromCharCode.apply(null, Array.from(crypto.getRandomValues(array))) + ) + .replace(/[^a-zA-Z0-9]/g, '') // Remove non-alphanumeric characters + .slice(0, length); // Ensure exact length +}; + +export const getHmacSeed = async ( + seed: string, + message: string +): Promise => { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(seed), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(message) + ); + return Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +}; + +export const getHmacBuffer = async ( + seed: string, + message: string +): Promise => { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(seed), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + return crypto.subtle.sign('HMAC', key, encoder.encode(message)); +}; + +export async function byteGenerator( + seed: string, + message: string, + rounds: number +): Promise { + const promises: Promise[] = []; + + for (let i = 0; i < rounds; i++) { + promises.push( + getHmacBuffer(seed, `${message}:${i}`).then(buf => new Uint8Array(buf)) + ); + } + + const buffers = await Promise.all(promises); + return buffers.flatMap(buffer => Array.from(buffer)); +} + +export const getGeneratedFloats = async ({ + count, + seed, + message, +}: { + count: number; + seed: string; + message: string; +}): Promise => { + const bytesNeeded = count * 4; + const rounds = Math.ceil(bytesNeeded / 32); // Each HMAC buffer gives 32 bytes + + const bytes = await byteGenerator(seed, message, rounds); + const selectedBytes = bytes.slice(0, bytesNeeded); + + return chunk(selectedBytes, 4).map(bytesChunk => + bytesChunk.reduce((result, value, i) => { + const divider = 256 ** (i + 1); + return result + value / divider; + }, 0) + ); +}; + +export const getFisherYatesShuffle = ({ + gameEvents, + stopCount, + totalEventsPossible, +}: { + gameEvents: number[]; + stopCount: number; + totalEventsPossible: number; +}): { array: number[]; chosenIndex: number }[] => { + if (gameEvents.length === 0) { + return []; + } + let eventNumbers = range(totalEventsPossible); + const outcomes = []; + const result: { array: number[]; chosenIndex: number }[] = [ + { array: [...eventNumbers], chosenIndex: gameEvents[0] }, + ]; + for (let i = 0; i < totalEventsPossible; i++) { + const chosenIndex = gameEvents[i]; + outcomes.push(eventNumbers[chosenIndex]); + if (outcomes.length === stopCount) { + break; + } + + eventNumbers = [ + ...eventNumbers.slice(0, chosenIndex), + ...eventNumbers.slice(chosenIndex + 1), + ]; + result.push({ + array: [...outcomes, ...eventNumbers], + chosenIndex: outcomes.length + gameEvents[i + 1], + }); + } + return result; +}; diff --git a/apps/frontend/src/lib/formatters.ts b/apps/frontend/src/lib/formatters.ts new file mode 100644 index 0000000..d0cd817 --- /dev/null +++ b/apps/frontend/src/lib/formatters.ts @@ -0,0 +1,64 @@ +export function formatCompactNumber(value: number, decimalPlaces = 10): string { + if (value === 0) return '0'; + + const absValue = Math.abs(value); + const sign = value < 0 ? '-' : ''; + + // Define thresholds and suffixes + const tiers = [ + { threshold: 1e12, suffix: 'T' }, + { threshold: 1e9, suffix: 'B' }, + { threshold: 1e6, suffix: 'M' }, + { threshold: 1e3, suffix: 'K' }, + { threshold: 1, suffix: '' }, + ]; + + // Find the appropriate tier + const tier = + tiers.find(t => absValue >= t.threshold) ?? tiers[tiers.length - 1]; + + // Calculate the scaled value + const scaledValue = absValue / tier.threshold; + + // Dynamically determine decimal places based on actual value + const formattedValue = + scaledValue % 1 === 0 + ? scaledValue.toFixed(0) + : scaledValue.toPrecision(decimalPlaces); + + // Remove trailing zeros while keeping at least one decimal if applicable + const trimmedValue = formattedValue + .replace(/(?\.\d*?[1-9])0+$/, '$') + .replace(/\.0+$/, ''); + + return `${sign}${trimmedValue}${tier.suffix}`; +} + +export function getYellowToRedColor(value: number, min = 0, max = 100): string { + // Ensure value is within bounds + const bounded = Math.max(min, Math.min(max, value)); + + // Calculate how far along the gradient we are (0 to 1) + const ratio = (bounded - min) / (max - min); + + // Start with yellow (255, 206, 0) and transition to dark red + // Gradually lower green from 206 to 0 + const green = Math.floor(206 * (1 - ratio)); + + // Also gradually lower red from 255 to 180 for a deeper red at high values + const red = Math.floor(255 - ratio * 75); + + return `rgb(${red}, ${green}, 0)`; +} + +export function formatColorizedNumber( + value: number, + min = 0, + max = 100, + decimalPlaces = 1 +): { formatted: string; color: string } { + return { + formatted: formatCompactNumber(value, decimalPlaces), + color: getYellowToRedColor(value, min, max), + }; +} diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts new file mode 100644 index 0000000..a7c2663 --- /dev/null +++ b/apps/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/apps/frontend/src/lib/verificationOutcomes.ts b/apps/frontend/src/lib/verificationOutcomes.ts new file mode 100644 index 0000000..95bb7b4 --- /dev/null +++ b/apps/frontend/src/lib/verificationOutcomes.ts @@ -0,0 +1,142 @@ +import { NO_OF_TILES } from '@repo/common/game-utils/mines/constants.js'; +import { + convertFloatsToGameEvents, + calculateMines, +} from '@repo/common/game-utils/mines/utils.js'; +import { convertFloatsToGameEvents as convertFloatsToGameEventsForBlackjack } from '@repo/common/game-utils/blackjack/utils.js'; + +import { NO_OF_TILES_KENO } from '@repo/common/game-utils/keno/constants.js'; +import { calculateSelectedGems } from '@repo/common/game-utils/keno/utils.js'; +import { Games } from '@/const/games'; +import type { Game } from '@/const/games'; +import { getGeneratedFloats } from './crypto'; + +interface MinesGameMeta { + minesCount: number; +} +export type GameMeta = MinesGameMeta | undefined; + +const diceVerificationOutcomes = async ({ + clientSeed, + serverSeed, + nonce, +}: { + clientSeed: string; + serverSeed: string; + nonce: number; +}): Promise => { + const [float] = await getGeneratedFloats({ + count: 1, + seed: serverSeed, + message: `${clientSeed}:${nonce}`, + }); + const result = (float * 10001) / 100; + return result.toFixed(2); +}; + +const minesVerificationOutcomes = async ({ + clientSeed, + serverSeed, + nonce, + meta, +}: { + clientSeed: string; + serverSeed: string; + nonce: number; + meta?: MinesGameMeta; +}): Promise => { + const minesCount = meta?.minesCount ?? 3; + const floats = await getGeneratedFloats({ + count: NO_OF_TILES - 1, + seed: serverSeed, + message: `${clientSeed}:${nonce}`, + }); + const gameEvents = convertFloatsToGameEvents(floats, NO_OF_TILES); + const mines = calculateMines(gameEvents, minesCount); + return mines; +}; + +const rouletteVerificationOutcomes = async ({ + clientSeed, + serverSeed, + nonce, +}: { + clientSeed: string; + serverSeed: string; + nonce: number; +}): Promise => { + const [float] = await getGeneratedFloats({ + count: 1, + seed: serverSeed, + message: `${clientSeed}:${nonce}`, + }); + const result = Math.floor(float * 37); + return result.toString(); +}; + +const kenoVerificationOutcomes = async ({ + clientSeed, + serverSeed, + nonce, +}: { + clientSeed: string; + serverSeed: string; + nonce: number; +}): Promise => { + const floats = await getGeneratedFloats({ + count: 10, + seed: serverSeed, + message: `${clientSeed}:${nonce}`, + }); + const gameEvents = convertFloatsToGameEvents(floats, NO_OF_TILES_KENO); + const drawnNumbers = calculateSelectedGems(gameEvents, 10).map( + num => num + 1 + ); + return drawnNumbers; +}; + +const blackjackVerificationOutcomes = async ({ + clientSeed, + serverSeed, + nonce, +}: { + clientSeed: string; + serverSeed: string; + nonce: number; +}): Promise => { + const floats = await getGeneratedFloats({ + count: 52, + seed: serverSeed, + message: `${clientSeed}:${nonce}`, + }); + return convertFloatsToGameEventsForBlackjack(floats); +}; + +export const getVerificationOutcome = async ({ + game, + clientSeed, + serverSeed, + nonce, + meta, +}: { + game: Game; + clientSeed: string; + serverSeed: string; + nonce: number; + meta?: GameMeta; +}): Promise => { + switch (game) { + case Games.DICE: + return diceVerificationOutcomes({ clientSeed, serverSeed, nonce }); + // case Games.ROULETTE: + // return rouletteVerificationOutcomes({ clientSeed, serverSeed, nonce }); + case Games.MINES: + return minesVerificationOutcomes({ clientSeed, serverSeed, nonce, meta }); + case Games.KENO: + return kenoVerificationOutcomes({ clientSeed, serverSeed, nonce }); + case Games.BLACKJACK: + return blackjackVerificationOutcomes({ clientSeed, serverSeed, nonce }); + default: + return ''; + } +}; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 0000000..c328776 --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import { createRoot } from 'react-dom/client'; +import { Toaster } from 'react-hot-toast'; +import App from './app'; +import './index.css'; + +const el = document.getElementById('root'); +if (el) { + const root = createRoot(el); + root.render( + <> + + + + ); +} else { + throw new Error('Could not find root element'); +} diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..61595c9 --- /dev/null +++ b/apps/frontend/src/routeTree.gen.ts @@ -0,0 +1,609 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +// Import Routes + +import { Route as rootRoute } from './routes/__root' +import { Route as PublicImport } from './routes/_public' +import { Route as ProtectedImport } from './routes/_protected' +import { Route as PublicIndexImport } from './routes/_public/index' +import { Route as PublicTermsAndConditionsImport } from './routes/_public/terms-and-conditions' +import { Route as PublicProvablyFairImport } from './routes/_public/provably-fair' +import { Route as PublicPrivacyPolicyImport } from './routes/_public/privacy-policy' +import { Route as ProtectedMyBetsImport } from './routes/_protected/my-bets' +import { Route as ProtectedCasinoImport } from './routes/_protected/casino' +import { Route as PublicProvablyFairIndexImport } from './routes/_public/provably-fair/index' +import { Route as PublicProvablyFairUnhashServerSeedImport } from './routes/_public/provably-fair/unhash-server-seed' +import { Route as PublicProvablyFairOverviewImport } from './routes/_public/provably-fair/overview' +import { Route as PublicProvablyFairImplementationImport } from './routes/_public/provably-fair/implementation' +import { Route as PublicProvablyFairGameEventsImport } from './routes/_public/provably-fair/game-events' +import { Route as PublicProvablyFairConversionsImport } from './routes/_public/provably-fair/conversions' +import { Route as PublicProvablyFairCalculationImport } from './routes/_public/provably-fair/calculation' +import { Route as ProtectedCasinoHomeImport } from './routes/_protected/casino/home' +import { Route as ProtectedCasinoGamesImport } from './routes/_protected/casino/games' +import { Route as ProtectedCasinoGamesGameIdImport } from './routes/_protected/casino/games/$gameId' + +// Create/Update Routes + +const PublicRoute = PublicImport.update({ + id: '/_public', + getParentRoute: () => rootRoute, +} as any) + +const ProtectedRoute = ProtectedImport.update({ + id: '/_protected', + getParentRoute: () => rootRoute, +} as any) + +const PublicIndexRoute = PublicIndexImport.update({ + id: '/', + path: '/', + getParentRoute: () => PublicRoute, +} as any) + +const PublicTermsAndConditionsRoute = PublicTermsAndConditionsImport.update({ + id: '/terms-and-conditions', + path: '/terms-and-conditions', + getParentRoute: () => PublicRoute, +} as any) + +const PublicProvablyFairRoute = PublicProvablyFairImport.update({ + id: '/provably-fair', + path: '/provably-fair', + getParentRoute: () => PublicRoute, +} as any) + +const PublicPrivacyPolicyRoute = PublicPrivacyPolicyImport.update({ + id: '/privacy-policy', + path: '/privacy-policy', + getParentRoute: () => PublicRoute, +} as any) + +const ProtectedMyBetsRoute = ProtectedMyBetsImport.update({ + id: '/my-bets', + path: '/my-bets', + getParentRoute: () => ProtectedRoute, +} as any) + +const ProtectedCasinoRoute = ProtectedCasinoImport.update({ + id: '/casino', + path: '/casino', + getParentRoute: () => ProtectedRoute, +} as any) + +const PublicProvablyFairIndexRoute = PublicProvablyFairIndexImport.update({ + id: '/', + path: '/', + getParentRoute: () => PublicProvablyFairRoute, +} as any) + +const PublicProvablyFairUnhashServerSeedRoute = + PublicProvablyFairUnhashServerSeedImport.update({ + id: '/unhash-server-seed', + path: '/unhash-server-seed', + getParentRoute: () => PublicProvablyFairRoute, + } as any) + +const PublicProvablyFairOverviewRoute = PublicProvablyFairOverviewImport.update( + { + id: '/overview', + path: '/overview', + getParentRoute: () => PublicProvablyFairRoute, + } as any, +) + +const PublicProvablyFairImplementationRoute = + PublicProvablyFairImplementationImport.update({ + id: '/implementation', + path: '/implementation', + getParentRoute: () => PublicProvablyFairRoute, + } as any) + +const PublicProvablyFairGameEventsRoute = + PublicProvablyFairGameEventsImport.update({ + id: '/game-events', + path: '/game-events', + getParentRoute: () => PublicProvablyFairRoute, + } as any) + +const PublicProvablyFairConversionsRoute = + PublicProvablyFairConversionsImport.update({ + id: '/conversions', + path: '/conversions', + getParentRoute: () => PublicProvablyFairRoute, + } as any) + +const PublicProvablyFairCalculationRoute = + PublicProvablyFairCalculationImport.update({ + id: '/calculation', + path: '/calculation', + getParentRoute: () => PublicProvablyFairRoute, + } as any) + +const ProtectedCasinoHomeRoute = ProtectedCasinoHomeImport.update({ + id: '/home', + path: '/home', + getParentRoute: () => ProtectedCasinoRoute, +} as any) + +const ProtectedCasinoGamesRoute = ProtectedCasinoGamesImport.update({ + id: '/games', + path: '/games', + getParentRoute: () => ProtectedCasinoRoute, +} as any) + +const ProtectedCasinoGamesGameIdRoute = ProtectedCasinoGamesGameIdImport.update( + { + id: '/$gameId', + path: '/$gameId', + getParentRoute: () => ProtectedCasinoGamesRoute, + } as any, +) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_protected': { + id: '/_protected' + path: '' + fullPath: '' + preLoaderRoute: typeof ProtectedImport + parentRoute: typeof rootRoute + } + '/_public': { + id: '/_public' + path: '' + fullPath: '' + preLoaderRoute: typeof PublicImport + parentRoute: typeof rootRoute + } + '/_protected/casino': { + id: '/_protected/casino' + path: '/casino' + fullPath: '/casino' + preLoaderRoute: typeof ProtectedCasinoImport + parentRoute: typeof ProtectedImport + } + '/_protected/my-bets': { + id: '/_protected/my-bets' + path: '/my-bets' + fullPath: '/my-bets' + preLoaderRoute: typeof ProtectedMyBetsImport + parentRoute: typeof ProtectedImport + } + '/_public/privacy-policy': { + id: '/_public/privacy-policy' + path: '/privacy-policy' + fullPath: '/privacy-policy' + preLoaderRoute: typeof PublicPrivacyPolicyImport + parentRoute: typeof PublicImport + } + '/_public/provably-fair': { + id: '/_public/provably-fair' + path: '/provably-fair' + fullPath: '/provably-fair' + preLoaderRoute: typeof PublicProvablyFairImport + parentRoute: typeof PublicImport + } + '/_public/terms-and-conditions': { + id: '/_public/terms-and-conditions' + path: '/terms-and-conditions' + fullPath: '/terms-and-conditions' + preLoaderRoute: typeof PublicTermsAndConditionsImport + parentRoute: typeof PublicImport + } + '/_public/': { + id: '/_public/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof PublicIndexImport + parentRoute: typeof PublicImport + } + '/_protected/casino/games': { + id: '/_protected/casino/games' + path: '/games' + fullPath: '/casino/games' + preLoaderRoute: typeof ProtectedCasinoGamesImport + parentRoute: typeof ProtectedCasinoImport + } + '/_protected/casino/home': { + id: '/_protected/casino/home' + path: '/home' + fullPath: '/casino/home' + preLoaderRoute: typeof ProtectedCasinoHomeImport + parentRoute: typeof ProtectedCasinoImport + } + '/_public/provably-fair/calculation': { + id: '/_public/provably-fair/calculation' + path: '/calculation' + fullPath: '/provably-fair/calculation' + preLoaderRoute: typeof PublicProvablyFairCalculationImport + parentRoute: typeof PublicProvablyFairImport + } + '/_public/provably-fair/conversions': { + id: '/_public/provably-fair/conversions' + path: '/conversions' + fullPath: '/provably-fair/conversions' + preLoaderRoute: typeof PublicProvablyFairConversionsImport + parentRoute: typeof PublicProvablyFairImport + } + '/_public/provably-fair/game-events': { + id: '/_public/provably-fair/game-events' + path: '/game-events' + fullPath: '/provably-fair/game-events' + preLoaderRoute: typeof PublicProvablyFairGameEventsImport + parentRoute: typeof PublicProvablyFairImport + } + '/_public/provably-fair/implementation': { + id: '/_public/provably-fair/implementation' + path: '/implementation' + fullPath: '/provably-fair/implementation' + preLoaderRoute: typeof PublicProvablyFairImplementationImport + parentRoute: typeof PublicProvablyFairImport + } + '/_public/provably-fair/overview': { + id: '/_public/provably-fair/overview' + path: '/overview' + fullPath: '/provably-fair/overview' + preLoaderRoute: typeof PublicProvablyFairOverviewImport + parentRoute: typeof PublicProvablyFairImport + } + '/_public/provably-fair/unhash-server-seed': { + id: '/_public/provably-fair/unhash-server-seed' + path: '/unhash-server-seed' + fullPath: '/provably-fair/unhash-server-seed' + preLoaderRoute: typeof PublicProvablyFairUnhashServerSeedImport + parentRoute: typeof PublicProvablyFairImport + } + '/_public/provably-fair/': { + id: '/_public/provably-fair/' + path: '/' + fullPath: '/provably-fair/' + preLoaderRoute: typeof PublicProvablyFairIndexImport + parentRoute: typeof PublicProvablyFairImport + } + '/_protected/casino/games/$gameId': { + id: '/_protected/casino/games/$gameId' + path: '/$gameId' + fullPath: '/casino/games/$gameId' + preLoaderRoute: typeof ProtectedCasinoGamesGameIdImport + parentRoute: typeof ProtectedCasinoGamesImport + } + } +} + +// Create and export the route tree + +interface ProtectedCasinoGamesRouteChildren { + ProtectedCasinoGamesGameIdRoute: typeof ProtectedCasinoGamesGameIdRoute +} + +const ProtectedCasinoGamesRouteChildren: ProtectedCasinoGamesRouteChildren = { + ProtectedCasinoGamesGameIdRoute: ProtectedCasinoGamesGameIdRoute, +} + +const ProtectedCasinoGamesRouteWithChildren = + ProtectedCasinoGamesRoute._addFileChildren(ProtectedCasinoGamesRouteChildren) + +interface ProtectedCasinoRouteChildren { + ProtectedCasinoGamesRoute: typeof ProtectedCasinoGamesRouteWithChildren + ProtectedCasinoHomeRoute: typeof ProtectedCasinoHomeRoute +} + +const ProtectedCasinoRouteChildren: ProtectedCasinoRouteChildren = { + ProtectedCasinoGamesRoute: ProtectedCasinoGamesRouteWithChildren, + ProtectedCasinoHomeRoute: ProtectedCasinoHomeRoute, +} + +const ProtectedCasinoRouteWithChildren = ProtectedCasinoRoute._addFileChildren( + ProtectedCasinoRouteChildren, +) + +interface ProtectedRouteChildren { + ProtectedCasinoRoute: typeof ProtectedCasinoRouteWithChildren + ProtectedMyBetsRoute: typeof ProtectedMyBetsRoute +} + +const ProtectedRouteChildren: ProtectedRouteChildren = { + ProtectedCasinoRoute: ProtectedCasinoRouteWithChildren, + ProtectedMyBetsRoute: ProtectedMyBetsRoute, +} + +const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( + ProtectedRouteChildren, +) + +interface PublicProvablyFairRouteChildren { + PublicProvablyFairCalculationRoute: typeof PublicProvablyFairCalculationRoute + PublicProvablyFairConversionsRoute: typeof PublicProvablyFairConversionsRoute + PublicProvablyFairGameEventsRoute: typeof PublicProvablyFairGameEventsRoute + PublicProvablyFairImplementationRoute: typeof PublicProvablyFairImplementationRoute + PublicProvablyFairOverviewRoute: typeof PublicProvablyFairOverviewRoute + PublicProvablyFairUnhashServerSeedRoute: typeof PublicProvablyFairUnhashServerSeedRoute + PublicProvablyFairIndexRoute: typeof PublicProvablyFairIndexRoute +} + +const PublicProvablyFairRouteChildren: PublicProvablyFairRouteChildren = { + PublicProvablyFairCalculationRoute: PublicProvablyFairCalculationRoute, + PublicProvablyFairConversionsRoute: PublicProvablyFairConversionsRoute, + PublicProvablyFairGameEventsRoute: PublicProvablyFairGameEventsRoute, + PublicProvablyFairImplementationRoute: PublicProvablyFairImplementationRoute, + PublicProvablyFairOverviewRoute: PublicProvablyFairOverviewRoute, + PublicProvablyFairUnhashServerSeedRoute: + PublicProvablyFairUnhashServerSeedRoute, + PublicProvablyFairIndexRoute: PublicProvablyFairIndexRoute, +} + +const PublicProvablyFairRouteWithChildren = + PublicProvablyFairRoute._addFileChildren(PublicProvablyFairRouteChildren) + +interface PublicRouteChildren { + PublicPrivacyPolicyRoute: typeof PublicPrivacyPolicyRoute + PublicProvablyFairRoute: typeof PublicProvablyFairRouteWithChildren + PublicTermsAndConditionsRoute: typeof PublicTermsAndConditionsRoute + PublicIndexRoute: typeof PublicIndexRoute +} + +const PublicRouteChildren: PublicRouteChildren = { + PublicPrivacyPolicyRoute: PublicPrivacyPolicyRoute, + PublicProvablyFairRoute: PublicProvablyFairRouteWithChildren, + PublicTermsAndConditionsRoute: PublicTermsAndConditionsRoute, + PublicIndexRoute: PublicIndexRoute, +} + +const PublicRouteWithChildren = + PublicRoute._addFileChildren(PublicRouteChildren) + +export interface FileRoutesByFullPath { + '': typeof PublicRouteWithChildren + '/casino': typeof ProtectedCasinoRouteWithChildren + '/my-bets': typeof ProtectedMyBetsRoute + '/privacy-policy': typeof PublicPrivacyPolicyRoute + '/provably-fair': typeof PublicProvablyFairRouteWithChildren + '/terms-and-conditions': typeof PublicTermsAndConditionsRoute + '/': typeof PublicIndexRoute + '/casino/games': typeof ProtectedCasinoGamesRouteWithChildren + '/casino/home': typeof ProtectedCasinoHomeRoute + '/provably-fair/calculation': typeof PublicProvablyFairCalculationRoute + '/provably-fair/conversions': typeof PublicProvablyFairConversionsRoute + '/provably-fair/game-events': typeof PublicProvablyFairGameEventsRoute + '/provably-fair/implementation': typeof PublicProvablyFairImplementationRoute + '/provably-fair/overview': typeof PublicProvablyFairOverviewRoute + '/provably-fair/unhash-server-seed': typeof PublicProvablyFairUnhashServerSeedRoute + '/provably-fair/': typeof PublicProvablyFairIndexRoute + '/casino/games/$gameId': typeof ProtectedCasinoGamesGameIdRoute +} + +export interface FileRoutesByTo { + '': typeof ProtectedRouteWithChildren + '/casino': typeof ProtectedCasinoRouteWithChildren + '/my-bets': typeof ProtectedMyBetsRoute + '/privacy-policy': typeof PublicPrivacyPolicyRoute + '/terms-and-conditions': typeof PublicTermsAndConditionsRoute + '/': typeof PublicIndexRoute + '/casino/games': typeof ProtectedCasinoGamesRouteWithChildren + '/casino/home': typeof ProtectedCasinoHomeRoute + '/provably-fair/calculation': typeof PublicProvablyFairCalculationRoute + '/provably-fair/conversions': typeof PublicProvablyFairConversionsRoute + '/provably-fair/game-events': typeof PublicProvablyFairGameEventsRoute + '/provably-fair/implementation': typeof PublicProvablyFairImplementationRoute + '/provably-fair/overview': typeof PublicProvablyFairOverviewRoute + '/provably-fair/unhash-server-seed': typeof PublicProvablyFairUnhashServerSeedRoute + '/provably-fair': typeof PublicProvablyFairIndexRoute + '/casino/games/$gameId': typeof ProtectedCasinoGamesGameIdRoute +} + +export interface FileRoutesById { + __root__: typeof rootRoute + '/_protected': typeof ProtectedRouteWithChildren + '/_public': typeof PublicRouteWithChildren + '/_protected/casino': typeof ProtectedCasinoRouteWithChildren + '/_protected/my-bets': typeof ProtectedMyBetsRoute + '/_public/privacy-policy': typeof PublicPrivacyPolicyRoute + '/_public/provably-fair': typeof PublicProvablyFairRouteWithChildren + '/_public/terms-and-conditions': typeof PublicTermsAndConditionsRoute + '/_public/': typeof PublicIndexRoute + '/_protected/casino/games': typeof ProtectedCasinoGamesRouteWithChildren + '/_protected/casino/home': typeof ProtectedCasinoHomeRoute + '/_public/provably-fair/calculation': typeof PublicProvablyFairCalculationRoute + '/_public/provably-fair/conversions': typeof PublicProvablyFairConversionsRoute + '/_public/provably-fair/game-events': typeof PublicProvablyFairGameEventsRoute + '/_public/provably-fair/implementation': typeof PublicProvablyFairImplementationRoute + '/_public/provably-fair/overview': typeof PublicProvablyFairOverviewRoute + '/_public/provably-fair/unhash-server-seed': typeof PublicProvablyFairUnhashServerSeedRoute + '/_public/provably-fair/': typeof PublicProvablyFairIndexRoute + '/_protected/casino/games/$gameId': typeof ProtectedCasinoGamesGameIdRoute +} + +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '' + | '/casino' + | '/my-bets' + | '/privacy-policy' + | '/provably-fair' + | '/terms-and-conditions' + | '/' + | '/casino/games' + | '/casino/home' + | '/provably-fair/calculation' + | '/provably-fair/conversions' + | '/provably-fair/game-events' + | '/provably-fair/implementation' + | '/provably-fair/overview' + | '/provably-fair/unhash-server-seed' + | '/provably-fair/' + | '/casino/games/$gameId' + fileRoutesByTo: FileRoutesByTo + to: + | '' + | '/casino' + | '/my-bets' + | '/privacy-policy' + | '/terms-and-conditions' + | '/' + | '/casino/games' + | '/casino/home' + | '/provably-fair/calculation' + | '/provably-fair/conversions' + | '/provably-fair/game-events' + | '/provably-fair/implementation' + | '/provably-fair/overview' + | '/provably-fair/unhash-server-seed' + | '/provably-fair' + | '/casino/games/$gameId' + id: + | '__root__' + | '/_protected' + | '/_public' + | '/_protected/casino' + | '/_protected/my-bets' + | '/_public/privacy-policy' + | '/_public/provably-fair' + | '/_public/terms-and-conditions' + | '/_public/' + | '/_protected/casino/games' + | '/_protected/casino/home' + | '/_public/provably-fair/calculation' + | '/_public/provably-fair/conversions' + | '/_public/provably-fair/game-events' + | '/_public/provably-fair/implementation' + | '/_public/provably-fair/overview' + | '/_public/provably-fair/unhash-server-seed' + | '/_public/provably-fair/' + | '/_protected/casino/games/$gameId' + fileRoutesById: FileRoutesById +} + +export interface RootRouteChildren { + ProtectedRoute: typeof ProtectedRouteWithChildren + PublicRoute: typeof PublicRouteWithChildren +} + +const rootRouteChildren: RootRouteChildren = { + ProtectedRoute: ProtectedRouteWithChildren, + PublicRoute: PublicRouteWithChildren, +} + +export const routeTree = rootRoute + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/_protected", + "/_public" + ] + }, + "/_protected": { + "filePath": "_protected.tsx", + "children": [ + "/_protected/casino", + "/_protected/my-bets" + ] + }, + "/_public": { + "filePath": "_public.tsx", + "children": [ + "/_public/privacy-policy", + "/_public/provably-fair", + "/_public/terms-and-conditions", + "/_public/" + ] + }, + "/_protected/casino": { + "filePath": "_protected/casino.jsx", + "parent": "/_protected", + "children": [ + "/_protected/casino/games", + "/_protected/casino/home" + ] + }, + "/_protected/my-bets": { + "filePath": "_protected/my-bets.tsx", + "parent": "/_protected" + }, + "/_public/privacy-policy": { + "filePath": "_public/privacy-policy.tsx", + "parent": "/_public" + }, + "/_public/provably-fair": { + "filePath": "_public/provably-fair.tsx", + "parent": "/_public", + "children": [ + "/_public/provably-fair/calculation", + "/_public/provably-fair/conversions", + "/_public/provably-fair/game-events", + "/_public/provably-fair/implementation", + "/_public/provably-fair/overview", + "/_public/provably-fair/unhash-server-seed", + "/_public/provably-fair/" + ] + }, + "/_public/terms-and-conditions": { + "filePath": "_public/terms-and-conditions.tsx", + "parent": "/_public" + }, + "/_public/": { + "filePath": "_public/index.tsx", + "parent": "/_public" + }, + "/_protected/casino/games": { + "filePath": "_protected/casino/games.tsx", + "parent": "/_protected/casino", + "children": [ + "/_protected/casino/games/$gameId" + ] + }, + "/_protected/casino/home": { + "filePath": "_protected/casino/home.tsx", + "parent": "/_protected/casino" + }, + "/_public/provably-fair/calculation": { + "filePath": "_public/provably-fair/calculation.tsx", + "parent": "/_public/provably-fair" + }, + "/_public/provably-fair/conversions": { + "filePath": "_public/provably-fair/conversions.tsx", + "parent": "/_public/provably-fair" + }, + "/_public/provably-fair/game-events": { + "filePath": "_public/provably-fair/game-events.tsx", + "parent": "/_public/provably-fair" + }, + "/_public/provably-fair/implementation": { + "filePath": "_public/provably-fair/implementation.tsx", + "parent": "/_public/provably-fair" + }, + "/_public/provably-fair/overview": { + "filePath": "_public/provably-fair/overview.tsx", + "parent": "/_public/provably-fair" + }, + "/_public/provably-fair/unhash-server-seed": { + "filePath": "_public/provably-fair/unhash-server-seed.tsx", + "parent": "/_public/provably-fair" + }, + "/_public/provably-fair/": { + "filePath": "_public/provably-fair/index.tsx", + "parent": "/_public/provably-fair" + }, + "/_protected/casino/games/$gameId": { + "filePath": "_protected/casino/games/$gameId.tsx", + "parent": "/_protected/casino/games" + } + } +} +ROUTE_MANIFEST_END */ diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..bf92f0d --- /dev/null +++ b/apps/frontend/src/routes/__root.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; +import { TanStackRouterDevtools } from '@tanstack/router-devtools'; +import type { AuthState } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/features/auth/store/authStore'; +import { setupInterceptors } from '@/api/_utils/axiosInstance'; +import { LoginModal } from '@/features/auth/components/LoginModal'; +import { Header } from '@/components/Header'; +import { z } from 'zod'; +import { GLOBAL_MODAL } from '@/features/global-modals/types'; +import GlobalModals from '@/features/global-modals'; +import { gameSchema, type Game, type Games } from '@/const/games'; + +interface RouterContext { + authStore: AuthState | undefined; +} + +// Types for each modal's search params +export const betModalSearchSchema = z.object({ + iid: z.number().optional(), + modal: z.literal(GLOBAL_MODAL.BET).optional(), +}); + +export const fairnessModalSearchSchema = z.object({ + game: gameSchema.optional(), + modal: z.literal(GLOBAL_MODAL.FAIRNESS).optional(), + tab: z.enum(['seeds', 'verify', 'overview']).default('seeds').optional(), + clientSeed: z.string().optional(), + serverSeed: z.string().optional(), + nonce: z.number().optional(), +}); + +// Union type for all modals +export const rootSearchSchema = z + .union([betModalSearchSchema, fairnessModalSearchSchema]) + .optional(); + +export const Route = createRootRouteWithContext()({ + validateSearch: rootSearchSchema, + component: RootLayout, +}); + +function RootLayout(): JSX.Element { + return ( + <> + + + + {import.meta.env.DEV ? : null} + + ); +} diff --git a/apps/frontend/src/routes/_protected.tsx b/apps/frontend/src/routes/_protected.tsx new file mode 100644 index 0000000..888c39f --- /dev/null +++ b/apps/frontend/src/routes/_protected.tsx @@ -0,0 +1,40 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { QueryClient } from '@tanstack/react-query'; +import { getAuthState } from '@/features/auth/store/authStore'; +import { getUserDetails } from '@/api/auth'; +import { Header } from '@/components/Header'; + +export const Route = createFileRoute('/_protected')({ + async beforeLoad({ context }) { + const { user, showLoginModal } = getAuthState(); + + if (!user) { + try { + // Fetch user details if not already authenticated + const queryClient = new QueryClient(); + const res = await queryClient.fetchQuery({ + queryKey: ['me'], + queryFn: getUserDetails, + retry: false, + }); + // Set user in auth store if fetch succeeds + context.authStore?.setUser(res.data); + } catch (error) { + // Instead of redirecting, show login modal + showLoginModal(); + } + } + }, + component: ProtectedLayout, +}); + +function ProtectedLayout(): JSX.Element { + return ( + <> +
+
+ +
+ + ); +} diff --git a/apps/frontend/src/routes/_protected/casino.jsx b/apps/frontend/src/routes/_protected/casino.jsx new file mode 100644 index 0000000..7a54fb0 --- /dev/null +++ b/apps/frontend/src/routes/_protected/casino.jsx @@ -0,0 +1,17 @@ +import { + createFileRoute, + MatchRoute, + Navigate, + Outlet, +} from '@tanstack/react-router'; + +export const Route = createFileRoute('/_protected/casino')({ + component: () => ( + <> + + + + + + ), +}); diff --git a/apps/frontend/src/routes/_protected/casino/games.tsx b/apps/frontend/src/routes/_protected/casino/games.tsx new file mode 100644 index 0000000..dc6147d --- /dev/null +++ b/apps/frontend/src/routes/_protected/casino/games.tsx @@ -0,0 +1,19 @@ +import { + createFileRoute, + MatchRoute, + Navigate, + Outlet, +} from '@tanstack/react-router'; + +export const Route = createFileRoute('/_protected/casino/games')({ + component: () => ( + <> + + , + +
+ +
+ + ), +}); diff --git a/apps/frontend/src/routes/_protected/casino/games/$gameId.tsx b/apps/frontend/src/routes/_protected/casino/games/$gameId.tsx new file mode 100644 index 0000000..8012f4e --- /dev/null +++ b/apps/frontend/src/routes/_protected/casino/games/$gameId.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { Roulette } from '@/features/games/roulette'; +import { Mines } from '@/features/games/mines'; +import { Plinkoo } from '@/features/games/plinkoo'; +import { DiceGame } from '@/features/games/dice'; +import { Keno } from '@/features/games/keno'; +import Blackjack from '@/features/games/blackjack'; + +export const Route = createFileRoute('/_protected/casino/games/$gameId')({ + component: GamePage, +}); + +function GamePage(): JSX.Element { + const { gameId } = Route.useParams(); + + switch (gameId) { + case 'dice': + return ; + case 'mines': + return ; + case 'plinkoo': + return ; + case 'keno': + return ; + case 'blackjack': + return ; + default: + return
Game not found
; + } +} diff --git a/apps/frontend/src/routes/_protected/casino/home.tsx b/apps/frontend/src/routes/_protected/casino/home.tsx new file mode 100644 index 0000000..2dff919 --- /dev/null +++ b/apps/frontend/src/routes/_protected/casino/home.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Home from '@/features/home'; + +export const Route = createFileRoute('/_protected/casino/home')({ + component: () => , +}); diff --git a/apps/frontend/src/routes/_protected/my-bets.tsx b/apps/frontend/src/routes/_protected/my-bets.tsx new file mode 100644 index 0000000..9804e51 --- /dev/null +++ b/apps/frontend/src/routes/_protected/my-bets.tsx @@ -0,0 +1,7 @@ +import MyBets from '@/features/my-bets'; +import { createFileRoute } from '@tanstack/react-router'; +import { rootSearchSchema } from '../__root'; + +export const Route = createFileRoute('/_protected/my-bets')({ + component: MyBets, +}); diff --git a/apps/frontend/src/routes/_public.tsx b/apps/frontend/src/routes/_public.tsx new file mode 100644 index 0000000..6624e36 --- /dev/null +++ b/apps/frontend/src/routes/_public.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { z } from 'zod'; + +export const Route = createFileRoute('/_public')({ + validateSearch: z.object({ + redirect: z.string().optional().catch(''), + }), +}); diff --git a/apps/frontend/src/routes/_public/index.tsx b/apps/frontend/src/routes/_public/index.tsx new file mode 100644 index 0000000..06d860d --- /dev/null +++ b/apps/frontend/src/routes/_public/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Landing from '@/features/landing'; + +export const Route = createFileRoute('/_public/')({ + component: RouteComponent, +}); + +function RouteComponent(): JSX.Element { + return ; +} diff --git a/apps/frontend/src/routes/_public/privacy-policy.tsx b/apps/frontend/src/routes/_public/privacy-policy.tsx new file mode 100644 index 0000000..0da9675 --- /dev/null +++ b/apps/frontend/src/routes/_public/privacy-policy.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import PrivacyPolicy from '@/features/privacy-policy'; + +export const Route = createFileRoute('/_public/privacy-policy')({ + component: PrivacyPolicy, +}); diff --git a/apps/frontend/src/routes/_public/provably-fair.tsx b/apps/frontend/src/routes/_public/provably-fair.tsx new file mode 100644 index 0000000..da96fe9 --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import ProvablyFair from '@/features/provaly-fair'; + +export const Route = createFileRoute('/_public/provably-fair')({ + component: ProvablyFair, +}); diff --git a/apps/frontend/src/routes/_public/provably-fair/calculation.tsx b/apps/frontend/src/routes/_public/provably-fair/calculation.tsx new file mode 100644 index 0000000..9fb5f69 --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/calculation.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router'; +import ProvablyFairCalculation from '@/features/provaly-fair/ProvablyFairCalculation'; +import { gameSchema } from '@/const/games'; +import { z } from 'zod'; + +export const calculationSearchSchema = z.object({ + game: gameSchema.optional(), + clientSeed: z.string().optional(), + serverSeed: z.string().optional(), + nonce: z.number().optional(), +}); + +export const Route = createFileRoute('/_public/provably-fair/calculation')({ + validateSearch: calculationSearchSchema, + component: ProvablyFairCalculation, +}); diff --git a/apps/frontend/src/routes/_public/provably-fair/conversions.tsx b/apps/frontend/src/routes/_public/provably-fair/conversions.tsx new file mode 100644 index 0000000..fd00c9c --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/conversions.tsx @@ -0,0 +1,10 @@ +import Conversions from '@/features/provaly-fair/Conversions'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_public/provably-fair/conversions')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/frontend/src/routes/_public/provably-fair/game-events.tsx b/apps/frontend/src/routes/_public/provably-fair/game-events.tsx new file mode 100644 index 0000000..3f27443 --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/game-events.tsx @@ -0,0 +1,10 @@ +import GameEvents from '@/features/provaly-fair/GameEvents'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_public/provably-fair/game-events')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/frontend/src/routes/_public/provably-fair/implementation.tsx b/apps/frontend/src/routes/_public/provably-fair/implementation.tsx new file mode 100644 index 0000000..d5769d3 --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/implementation.tsx @@ -0,0 +1,10 @@ +import Implementation from '@/features/provaly-fair/Implementation'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_public/provably-fair/implementation')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/frontend/src/routes/_public/provably-fair/index.tsx b/apps/frontend/src/routes/_public/provably-fair/index.tsx new file mode 100644 index 0000000..4f912dc --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/index.tsx @@ -0,0 +1,10 @@ +import Overview from '@/features/provaly-fair/Overview'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_public/provably-fair/')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/frontend/src/routes/_public/provably-fair/overview.tsx b/apps/frontend/src/routes/_public/provably-fair/overview.tsx new file mode 100644 index 0000000..7148b4c --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/overview.tsx @@ -0,0 +1,10 @@ +import Overview from '@/features/provaly-fair/Overview'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_public/provably-fair/overview')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/frontend/src/routes/_public/provably-fair/unhash-server-seed.tsx b/apps/frontend/src/routes/_public/provably-fair/unhash-server-seed.tsx new file mode 100644 index 0000000..13fae9a --- /dev/null +++ b/apps/frontend/src/routes/_public/provably-fair/unhash-server-seed.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router'; +import UnhashServerSeed from '@/features/provaly-fair/UnhashServerSeed'; + +export const Route = createFileRoute( + '/_public/provably-fair/unhash-server-seed' +)({ + component: UnhashServerSeed, +}); diff --git a/apps/frontend/src/routes/_public/terms-and-conditions.tsx b/apps/frontend/src/routes/_public/terms-and-conditions.tsx new file mode 100644 index 0000000..2af6a5e --- /dev/null +++ b/apps/frontend/src/routes/_public/terms-and-conditions.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import TermsAndConditions from '@/features/terms-and-conditions'; + +export const Route = createFileRoute('/_public/terms-and-conditions')({ + component: TermsAndConditions, +}); diff --git a/apps/frontend/src/store/gameSettings.ts b/apps/frontend/src/store/gameSettings.ts new file mode 100644 index 0000000..1e5e76d --- /dev/null +++ b/apps/frontend/src/store/gameSettings.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface GameSettingsState { + // Audio settings + volume: number; + + // Visual settings + animations: boolean; + + // Betting settings + showMaxBetButton: boolean; + instantBet: boolean; + + // Controls + hotkeysEnabled: boolean; + + // Actions + setVolume: (volume: number) => void; + setAnimations: (enabled: boolean) => void; + setShowMaxBetButton: (show: boolean) => void; + setInstantBet: (enabled: boolean) => void; + setHotkeysEnabled: (enabled: boolean) => void; + + // Reset all settings to defaults + resetToDefaults: () => void; +} + +// Default settings +const defaultSettings = { + volume: 100, + animations: true, + showMaxBetButton: false, + instantBet: false, + hotkeysEnabled: false, +}; + +export const useGameSettingsStore = create()( + persist( + set => ({ + // Initial state with defaults + ...defaultSettings, + + // Actions to update individual settings + setVolume: volume => { + // Ensure volume is between 0 and 100 + const validVolume = Math.max(0, Math.min(100, volume)); + set({ volume: validVolume }); + }, + + setAnimations: enabled => { + set({ animations: enabled }); + }, + + setShowMaxBetButton: show => { + set({ showMaxBetButton: show }); + }, + + setInstantBet: enabled => { + set({ instantBet: enabled }); + }, + + setHotkeysEnabled: enabled => { + set({ hotkeysEnabled: enabled }); + }, + + // Reset all settings to default values + resetToDefaults: () => { + set(defaultSettings); + }, + }), + { + name: 'game-settings-storage', // localStorage key + // Sync with localStorage immediately + skipHydration: false, + } + ) +); diff --git a/apps/frontend/src/store/index.ts b/apps/frontend/src/store/index.ts new file mode 100644 index 0000000..f147e88 --- /dev/null +++ b/apps/frontend/src/store/index.ts @@ -0,0 +1 @@ +export { useGameSettingsStore } from './gameSettings'; diff --git a/apps/frontend/tailwind.config.js b/apps/frontend/tailwind.config.js new file mode 100644 index 0000000..fcca050 --- /dev/null +++ b/apps/frontend/tailwind.config.js @@ -0,0 +1,290 @@ +const plugin = require('tailwindcss/plugin'); + +const iconTokens = { + 'neutral-weakest': '#557086', + 'neutral-weaker': '#2f4553', + 'neutral-weak': '#b1b4d3', + 'neutral-default': '#fff', + 'neutral-strong': '#1a2c38', + 'neutral-stronger': '#0f212e', + 'neutral-strongest': '#071824', + 'primary-foreground': 'hsl(var(--primary-foreground))', +}; + +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ['selector', 'class'], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + './index.html', + ], + prefix: '', + theme: { + container: { + center: true, + padding: { + DEFAULT: '1rem', + sm: '1.5rem', + md: '2rem', + lg: '2.5rem', + xl: '3rem', + }, + screens: { + xl: '1200px', + lg: '1024px', + md: '768px', + sm: '640px', + }, + }, + screens: { + sm: '400px', + md: '640px', + lg: '1024px', + xl: '1200px', + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: { + DEFAULT: 'hsl(var(--input))', + disabled: 'hsl(var(--input-disabled))', + }, + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + light: 'hsl(var(--secondary-light))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + glow: 'hsl(var(--accent-glow))', + }, + 'accent-purple': { + DEFAULT: 'hsl(var(--accent-purple))', + foreground: 'hsl(var(--accent-foreground))', + glow: 'hsl(var(--accent-glow))', + }, + success: { + DEFAULT: 'hsl(var(--success))', + foreground: 'hsl(var(--success-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + chart: { + 1: 'hsl(var(--chart-1))', + 2: 'hsl(var(--chart-2))', + 3: 'hsl(var(--chart-3))', + 4: 'hsl(var(--chart-4))', + 5: 'hsl(var(--chart-5))', + }, + }, + borderWidth: { + 3: '3px', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + gridTemplateColumns: { + 14: 'repeat(14, minmax(0, 1fr))', + }, + gridColumn: { + span14: 'span 14 / span 14', + }, + keyframes: { + 'accordion-down': { + from: { + height: '0', + }, + to: { + height: 'var(--radix-accordion-content-height)', + }, + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)', + }, + to: { + height: '0', + }, + }, + slideInLeft: { + '0%': { transform: 'translateX(20px)', opacity: 0 }, + '100%': { transform: 'translateX(0)', opacity: 1 }, + }, + slideOutLeft: { + '0%': { transform: 'translateX(0)', opacity: 1 }, + '100%': { transform: 'translateX(-20px)', opacity: 0 }, + }, + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-20px)' }, + }, + 'fade-in': { + '0%': { + opacity: '0', + transform: 'translateY(10px)', + }, + '100%': { + opacity: '1', + transform: 'translateY(0)', + }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + slideInLeft: 'slideInLeft 600ms ease-out', + slideOutLeft: 'slideOutLeft 600ms ease-out', + float: 'float 6s ease-in-out infinite', + 'fade-in': 'fade-in 0.3s ease-out', + }, + fontFamily: { + custom: ['Montserrat', 'sans-serif'], + }, + boxShadow: { + glow: 'var(--shadow-glow)', + card: 'var(--shadow-card)', + }, + backgroundColor: ({ theme }) => ({ + ...theme('colors'), + 'neutral-weak': '#557086', + 'neutral-weaker': '#2f4553', + 'neutral-weak': '#b1b4d3', + 'neutral-default': '#fff', + 'neutral-strong': '#1a2c38', + 'neutral-stronger': '#0f212e', + 'neutral-strongest': '#1a2c38', + 'brand-weakest': '#557086', + 'brand-weaker': '#2f4553', + 'brand-weak': '#213743', + 'brand-default': '#1a2c38', + 'brand-strong': '#1a2c38', + 'brand-stronger': '#0f212e', + 'brand-strongest': '#071824', + 'roulette-red': '#fe2247', + 'roulette-red-hover': '#fe6e86', + 'roulette-black': '#2f4553', + 'roulette-black-hover': '#4b6e84', + 'roulette-green': '#419e3f', + 'roulette-green-hover': '#69c267', + 'keno-selected-tile': '#962EFF', + 'keno-selected-tile-hover': 'rgb(176, 97, 255)', + }), + backgroundImage: { + 'gradient-primary': 'var(--gradient-primary)', + 'gradient-subtle': 'var(--gradient-subtle)', + 'gradient-glow': 'var(--gradient-glow)', + }, + textColor: ({ theme }) => ({ + 'roulette-red': '#fe2247', + 'roulette-red-hover': '#fe6e86', + 'roulette-black': '#2f4553', + 'roulette-black-hover': '#4b6e84', + 'roulette-green': '#419e3f', + 'roulette-green-hover': '#69c267', + 'neutral-weak': '#557086', + 'neutral-weaker': '#2f4553', + 'neutral-weak': '#b1b4d3', + 'neutral-default': '#fff', + 'neutral-strong': '#1a2c38', + 'neutral-stronger': '#0f212e', + 'neutral-strongest': '#1a2c38', + 'brand-weakest': '#1a2c38', + 'brand-weaker': '#1a2c38', + 'brand-weak': '#1a2c38', + 'brand-default': '#1a2c38', + 'brand-strong': '#1a2c38', + 'brand-stronger': '#1a2c38', + 'brand-strongest': '#071824', + ...theme('colors'), + }), + borderColor: ({ theme }) => ({ + 'roulette-red': '#fe2247', + 'roulette-red-hover': '#fe6e86', + 'roulette-black': '#2f4553', + 'roulette-black-hover': '#4b6e84', + 'roulette-green': '#419e3f', + 'roulette-green-hover': '#69c267', + 'neutral-weak': '#557086', + 'neutral-weaker': '#2f4553', + 'neutral-weak': '#b1b4d3', + 'neutral-default': '#fff', + 'neutral-strong': '#1a2c38', + 'neutral-stronger': '#0f212e', + 'neutral-strongest': '#1a2c38', + 'brand-weakest': '#557086', + 'brand-weaker': '#2f4553', + 'brand-weak': '#213743', + 'brand-default': '#1a2c38', + 'brand-strong': '#1a2c38', + 'brand-stronger': '#0f212e', + 'brand-strongest': '#071824', + ...theme('colors'), + }), + fill: ({ theme }) => ({ + 'neutral-weak': '#557086', + 'neutral-weaker': '#2f4553', + 'neutral-weak': '#b1b4d3', + 'neutral-default': '#fff', + 'neutral-strong': '#1a2c38', + 'neutral-stronger': '#0f212e', + 'neutral-strongest': '#1a2c38', + 'brand-weakest': '#557086', + 'brand-weaker': '#2f4553', + 'brand-weak': '#213743', + 'brand-default': '#1a2c38', + 'brand-strong': '#1a2c38', + 'brand-stronger': '#0f212e', + 'brand-strongest': '#071824', + ...theme('colors'), + }), + }, + transitionProperty: { + smooth: 'var(--transition-smooth)', + glow: 'var(--transition-glow)', + }, + }, + safelist: [ + ...Array.from({ length: 20 }, (_, i) => `row-start-${i + 1}`), + ...Array.from({ length: 20 }, (_, i) => `row-end-${i + 1}`), + ], + plugins: [ + require('tailwindcss-animate'), + plugin(({ matchUtilities }) => { + matchUtilities( + { + icon: value => ({ color: value }), + }, + { + values: { ...iconTokens }, + } + ); + }), + ], +}; diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000..4935dd4 --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "exclude": ["node_modules"], + "extends": "@repo/typescript-config/vite.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@repo/*": ["../../packages/*/src"] + }, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/frontend/tsr.config.json b/apps/frontend/tsr.config.json new file mode 100644 index 0000000..7f36ce5 --- /dev/null +++ b/apps/frontend/tsr.config.json @@ -0,0 +1,3 @@ +{ + "autoCodeSplitting": true +} diff --git a/apps/frontend/turbo.json b/apps/frontend/turbo.json new file mode 100644 index 0000000..35be9c6 --- /dev/null +++ b/apps/frontend/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/apps/frontend/vercel.json b/apps/frontend/vercel.json new file mode 100644 index 0000000..00e7ecc --- /dev/null +++ b/apps/frontend/vercel.json @@ -0,0 +1,3 @@ +{ + "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] +} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts new file mode 100644 index 0000000..470abb0 --- /dev/null +++ b/apps/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; + +export default defineConfig({ + plugins: [ + TanStackRouterVite({ target: 'react', autoCodeSplitting: true }), + react(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@repo': path.resolve(__dirname, '../../packages'), + }, + }, +}); diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index f06235c..0000000 --- a/backend/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist diff --git a/backend/package-lock.json b/backend/package-lock.json deleted file mode 100644 index dcb5bac..0000000 --- a/backend/package-lock.json +++ /dev/null @@ -1,816 +0,0 @@ -{ - "name": "backend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "backend", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "cors": "^2.8.5", - "express": "^4.19.2" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", - "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" - }, - "node_modules/@types/node": { - "version": "20.12.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", - "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/qs": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - } - } -} diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index ed509b2..0000000 --- a/backend/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "backend", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "build": "tsc -b", - "start": "node dist/index.js" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "cors": "^2.8.5", - "express": "^4.19.2" - } -} diff --git a/backend/src/index.ts b/backend/src/index.ts deleted file mode 100644 index a1206d8..0000000 --- a/backend/src/index.ts +++ /dev/null @@ -1,53 +0,0 @@ - -import express from "express"; -import { outcomes } from "./outcomes"; -import cors from "cors"; - -const app = express(); -app.use(cors()) - -const TOTAL_DROPS = 16; - -const MULTIPLIERS: {[ key: number ]: number} = { - 0: 16, - 1: 9, - 2: 2, - 3: 1.4, - 4: 1.4, - 5: 1.2, - 6: 1.1, - 7: 1, - 8: 0.5, - 9: 1, - 10: 1.1, - 11: 1.2, - 12: 1.4, - 13: 1.4, - 14: 2, - 15: 9, - 16: 16 -} - -app.post("/game", (req, res) => { - let outcome = 0; - const pattern = [] - for (let i = 0; i < TOTAL_DROPS; i++) { - if (Math.random() > 0.5) { - pattern.push("R") - outcome++; - } else { - pattern.push("L") - } - } - - const multiplier = MULTIPLIERS[outcome]; - const possiblieOutcomes = outcomes[outcome]; - - res.send({ - point: possiblieOutcomes[Math.floor(Math.random() * possiblieOutcomes.length || 0)], - multiplier, - pattern - }); -}); - -app.listen(3000) \ No newline at end of file diff --git a/backend/src/outcomes.ts b/backend/src/outcomes.ts deleted file mode 100644 index f60a237..0000000 --- a/backend/src/outcomes.ts +++ /dev/null @@ -1 +0,0 @@ -export const outcomes: {[key: string]: number[]} = { "0": [], "1": [ 3964963.452981615, 3910113.3998412564 ], "2": [ 3980805.7004139693, 3945617.6504109767, 4027628.395823398, 3902115.8620758583, 3938709.5467746584 ], "3": [ 3975554.824601942, 3965805.769610554, 3909279.443666201, 3940971.550465178, 3909606.717374134, 3915484.1741136736, 3977018.430328505, 3979167.5933461944, 3995981.0273005674, 3974177.78840204 ], "4": [ 3943174.7607756723, 3992961.0886867167, 3914511.2798374896, 3950487.300703086, 3973378.3900412438, 4012888.985549594, 4040961.8767680754, 4066503.3857407006, 3944573.7194061875, 3979876.769324002, 4042712.772834604, 4032991.0303322095, 4046340.7919081766, 3912597.9665436875, 4068852.495940549, 4064879.257329362, 3996796.04239161, 4045062.2783860737, 3964680.919169739 ], "5": [ 3953045.1447091424, 3947374.62976226, 3924082.6101653073, 3919085.269354398, 3902650.4008744615, 3934968.1593932374, 4044126.7590222214, 3928499.8807134246, 3913801.9247018984, 3909595.4432100505, 4082827.827013994, 3979739.108665962, 4077651.317785833, 4008030.8883127486, 3950951.6007580766, 3992039.9053288833, 4021810.0928285993, 4052650.560434505, 3994806.267259329, 3959327.3735489477, 3940455.7641962855, 3998822.2807239015, 3998803.9335444313, 4068193.3913483596, 3938798.911585438 ], "6": [ 4065643.7049927213, 3936841.961313155, 3948472.8991447487, 4004510.5975928125, 3933695.6888747592, 4011296.1958215656, 4093232.84383817, 3945658.6170622837, 4063199.5117669366, 4037864.799653558, 3931477.3517858014, 4091381.513010509, 4000895.053297006, 4042867.6535872207, 4090947.938511616, 3989468.333758437, 3943335.764879169, 3947278.536321405, 4022304.817103859, 3902177.8466275427, 3925270.959381573, 3955253.4540312397, 3986641.0060988157, 3927696.2396482667, 4064571.150949869, 3991167.946685552, 3973041.308793569, 3987377.180906899, 3917262.667253392, 4002606.795366179, 4033596.992526079, 3901372.366183016, 4015207.583244224, 3955421.290959922, 3952223.0425123484, 3941774.4498685915, 3977289.3718391117, 4024943.3014183883, 4024885.5052148327, 4016596.7449097126, 3910164.1864616796, 4023400.498352244, 3981421.8628830933, 3913377.3496230906, 4045958.9425667236, 4071139.892029292, 4019862.922309672, 4027992.2300945413, 4030455.1701347437, 4060673.10227606, 3996564.062673036, 4009801.4052053, 4007734.404953163, 4046612.754675019, 3944956.9979153597, 3977382.889196781, 3906636.5132748624, 4080470.0674178666, 3996210.4877184015, 3956216.294023866, 3940040.183231992 ], "7": [ 3926739.9104774813, 4091374.44234272, 4061919.9903071183, 3976066.7555194413, 3948801.1936986246, 4043233.7830772344, 4010011.7658794387, 3936431.4108806592, 3942776.8649452417, 3909995.011479453, 4012272.43979473, 3989907.069429411, 3996182.4336681785, 4078644.79693604, 4081624.0834239917, 4025044.731614778, 4033602.5381773794, 3913189.826642105, 3910500.674962151, 4055296.6588616692, 4005574.8641647273, 4079800.3518520766, 4092763.5236495608, 3952185.4910905147, 3945510.495018459, 3920891.8818843197, 3997101.789672143, 3991974.822516503, 3949265.4371072412, 3933412.4749754136, 3933181.8312838264, 4063875.6616431624, 3998206.7252218956, 3959006.1987530286, 3924067.917601976, 3902914.4459602935, 3905347.098696195, 4000831.565288375, 3944915.3251241, 3930343.481158048, 4025858.616981573, 4026496.026592473, 3948116.019901921, 4067143.737297127, 3995156.000931595, 3905006.3301882823, 4035783.4852589793, 3956461.6106608217, 4032886.6912715673, 3913146.10237042, 3930772.085213345, 3984887.619042549, 4053031.0321973227, 3913395.137097174, 3993579.678508536, 3932427.236196532, 3984279.0886106077 ], "8": [ 4099062.75134143, 4085894.4181278455, 3991123.0115790954, 3973053.5827605873, 3968190.564301313, 3925604.5066868863, 3933898.7590061547, 4089919.7991958153, 4076997.5225973814, 3957630.60529322, 3948999.35996541, 3963938.9455971997, 4044805.7991237757, 3905133.2109927135, 4074463.6876271376, 3939301.0655442886, 4040571.320635691, 4020510.19979044, 3959835.4618981928, 4037241.67248416, 4043105.87901907, 3912654.2409310103, 3929773.262095125, 3950802.527033251, 4068582.4605300324, 3946792.6177569656, 4078475.9982660934, 3972024.763383927, 3947150.677862883, 3963410.9779685168, 3999134.851845996, 3909374.1117644133, 3942761.896008833, 4071253.4107468165, 4050534.50171971, 3988521.4618817912, 3929940.089627246, 4029305.1056314665, 4087943.221841722, 3910909.3079385986, 4046944.0552393594, 4006944.159180551, 4014707.657017377, 3925473.574267122, 4012158.905329344, 4042197.149473071, 3998434.6078570196, 4047267.2747256896, 3964753.3725316986, 3955821.0222197613, 3973475.662585886, 3917189.0280630635, 4027132.7848505056, 3905368.7668914935, 3936654.62186107, 4092566.3229272505, 4026541.0685970024, 4038770.6420815475, 4067262.4257867294, 4050430.5327158393, 3980149.8069138955, 4052184.5678737606, 3942299.598280835, 4079754.687607573, 4021112.5651541506, 3961023.3381184433, 3937025.1424917267, 3964607.486702018, 4001319.0133674755, 3941648.5232227165, 4030587.9685114417, 4044067.1579758436, 4058158.522928313 ], "9": [ 3911530.315770063, 4024711.492410591, 3967652.4297853387, 4098886.3793751886, 4026117.0283389515, 4045045.4095477182, 4034571.220507859, 4088809.303306565, 3900806.968890352, 3913166.9251142726, 4059594.3600833854, 3945137.694311404, 3902668.8160601873, 4054646.2889849013, 4053898.6542759663, 3959251.11275926, 3963475.882565954, 3967968.9310842347, 4075078.929914972, 4035117.4533019722, 4047608.2592268144, 3913024.5010530455, 4081362.0390194473, 4098538.7144543654, 4049336.7774994993, 4056844.5727342237, 3917845.6810319433, 4098332.1779752634, 3979547.7686487637, 4026747.155594485, 3944692.803167993, 3960649.105237204, 4081040.2295870385, 4005698.9658651184, 4074183.694152899, 3976184.3586868607, 4007157.5084493076, 3918927.3398626954, 3918166.0285542854, 3953868.3374998523, 3963648.6249533077, 4065036.1837552087, 3964230.698479104, 3992799.530672317, 3931113.922813188, 4082916.6661583954, 3919236.111874976, 4012743.1541231154, 3900406.2441578982, 4031396.764516756, 4088712.2834741194, 3921570.4946371615, 4077416.64169384, 3962807.6000533635 ], "10": [ 4069582.648305392, 3966300.3577461895, 4047184.7847023425, 3962656.256238744, 3934682.0223851865, 4089620.291559703, 3996605.065672608, 3921656.567101851, 3950930.30704122, 4052733.606190915, 4046762.051641918, 3912718.72211605, 3942094.6698735086, 4017504.735499972, 4016206.1612997893, 4060896.040328729, 4077224.686824909, 3988932.185505723, 4016550.502499315, 3959104.134236025, 3903531.023685199, 3939907.5585800377, 3969464.753065079, 4036549.7059165714, 3938844.715578784, 3985594.4268763512, 4011615.276676018, 3949739.058361909, 4064041.8926257566, 4004767.498301687, 3996411.8026064364, 4035064.3182208547, 3988008.7378418343, 4015638.96642283, 3967068.722994021, 4082965.2856357233, 3951302.134707721, 3948101.1830631103, 3978745.8509503608, 4068638.265329366, 4018433.726155858, 4032765.523475676 ], "11": [ 4055462.593704495, 4027576.362231998, 4011290.7395424685, 4034848.6574270525, 4064298.598636101, 3997022.919190929, 4053625.932623065, 4064234.3514714935, 4075348.9710445153, 4060118.5348266517, 4065992.932112665, 4063162.143518177, 4060798.1858924176, 3956764.654354398, 3912916.1668887464, 4018282.0763658765, 4065575.3280486814, 3967348.3916016137, 4034992.477051428, 4069123.2018048204, 3939281.4172981237, 4022103.802712647, 4083993.320300048, 4034478.871034405, 4068844.513451607, 4097187.535489012, 3981130.4047553614, 4068312.6406908804, 4050921.0879167155, 4048297.277514315, 3953878.475004285, 3998627.3710734197 ], "12": [ 4007152.5182738686, 4014664.8542149696, 4095619.5802802853, 4018084.7270321106, 4072050.3744347296, 4026256.723716898, 4095827.9573665825, 4023631.9896559394, 4046751.9125588783, 3973758.674124694, 4081927.075527175, 3922485.387310559, 4001549.2805312183, 4050417.849670596, 3987607.4531957353, 4060206.9664999805, 4080316.8473846694, 4030455.1532406537, 4087714.965906726, 4028165.0792610054, 4032588.5261474997, 3980546.468460318, 4090408.033691761, 3990019.103297975, 4088755.998466496, 4092162.22327816, 4029036.6583707742, 4055066.505591603, 4081998.821392285, 4079550.553314541 ], "13": [ 3905319.849889843, 4054719.0660902266, 4055596.4319745116, 3992648.989962779, 3924972.5941170114, 4095167.7814041013, 3912740.1944122575, 4024882.9438952096, 4023171.3988155797, 4059892.954049364, 4068510.96886605, 4093838.431690223, 4070524.1327491063 ], "14": [ 4092261.8249403643, 3956304.3865069468, 4069053.2302732924, 4038890.8473817194 ], "15": [ 4013891.110502415, 3977489.9532032954, 4044335.989753631, 4066199.8081775964 ], "16": [ 3979706.1687804307, 4024156.037977316 ], "17": [] } \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json deleted file mode 100644 index a645c5b..0000000 --- a/backend/tsconfig.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ - "rootDir": "./src", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } -} diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs deleted file mode 100644 index d6c9537..0000000 --- a/frontend/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 0d6babe..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} -``` - -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index e4b78ea..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 917abf8..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,4362 +0,0 @@ -{ - "name": "frontend", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "frontend", - "version": "0.0.0", - "dependencies": { - "axios": "^1.7.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^5.2.1", - "react-router-dom": "^6.23.1" - }, - "devDependencies": { - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", - "@typescript-eslint/eslint-plugin": "^7.2.0", - "@typescript-eslint/parser": "^7.2.0", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.6", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.3", - "typescript": "^5.2.2", - "vite": "^5.2.0" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz", - "integrity": "sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.24.5", - "@babel/helpers": "^7.24.5", - "@babel/parser": "^7.24.5", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.5", - "@babel/types": "^7.24.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz", - "integrity": "sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.5", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz", - "integrity": "sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.24.3", - "@babel/helper-simple-access": "^7.24.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "@babel/helper-validator-identifier": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", - "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz", - "integrity": "sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", - "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", - "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.5.tgz", - "integrity": "sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q==", - "dev": true, - "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.5", - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz", - "integrity": "sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.5", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", - "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.5.tgz", - "integrity": "sha512-RtCJoUO2oYrYwFPtR1/jkoBEcFuI1ae9a9IMxeyAVa3a1Ap4AnxmyIKG2b2FaJKqkidw/0cxRbWN+HOs6ZWd1w==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", - "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz", - "integrity": "sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "@babel/parser": "^7.24.5", - "@babel/types": "^7.24.5", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@remix-run/router": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", - "integrity": "sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", - "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz", - "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz", - "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz", - "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz", - "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz", - "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz", - "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz", - "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz", - "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz", - "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz", - "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz", - "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz", - "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz", - "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz", - "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz", - "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", - "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true - }, - "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true - }, - "node_modules/@types/react": { - "version": "18.3.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.2.tgz", - "integrity": "sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.9.0.tgz", - "integrity": "sha512-6e+X0X3sFe/G/54aC3jt0txuMTURqLyekmEHViqyA2VnxhLMpvA6nqmcjIy+Cr9tLDHPssA74BP5Mx9HQIxBEA==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.9.0", - "@typescript-eslint/type-utils": "7.9.0", - "@typescript-eslint/utils": "7.9.0", - "@typescript-eslint/visitor-keys": "7.9.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.9.0.tgz", - "integrity": "sha512-qHMJfkL5qvgQB2aLvhUSXxbK7OLnDkwPzFalg458pxQgfxKDfT1ZDbHQM/I6mDIf/svlMkj21kzKuQ2ixJlatQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "7.9.0", - "@typescript-eslint/types": "7.9.0", - "@typescript-eslint/typescript-estree": "7.9.0", - "@typescript-eslint/visitor-keys": "7.9.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.9.0.tgz", - "integrity": "sha512-ZwPK4DeCDxr3GJltRz5iZejPFAAr4Wk3+2WIBaj1L5PYK5RgxExu/Y68FFVclN0y6GGwH8q+KgKRCvaTmFBbgQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.9.0", - "@typescript-eslint/visitor-keys": "7.9.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.9.0.tgz", - "integrity": "sha512-6Qy8dfut0PFrFRAZsGzuLoM4hre4gjzWJB6sUvdunCYZsYemTkzZNwF1rnGea326PHPT3zn5Lmg32M/xfJfByA==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "7.9.0", - "@typescript-eslint/utils": "7.9.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.9.0.tgz", - "integrity": "sha512-oZQD9HEWQanl9UfsbGVcZ2cGaR0YT5476xfWE0oE5kQa2sNK2frxOlkeacLOTh9po4AlUT5rtkGyYM5kew0z5w==", - "dev": true, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.9.0.tgz", - "integrity": "sha512-zBCMCkrb2YjpKV3LA0ZJubtKCDxLttxfdGmwZvTqqWevUPN0FZvSI26FalGFFUZU/9YQK/A4xcQF9o/VVaCKAg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.9.0", - "@typescript-eslint/visitor-keys": "7.9.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.9.0.tgz", - "integrity": "sha512-5KVRQCzZajmT4Ep+NEgjXCvjuypVvYHUW7RHlXzNPuak2oWpVoD1jf5xCP0dPAuNIchjC7uQyvbdaSTFaLqSdA==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.9.0", - "@typescript-eslint/types": "7.9.0", - "@typescript-eslint/typescript-estree": "7.9.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.9.0.tgz", - "integrity": "sha512-iESPx2TNLDNGQLyjKhUvIKprlP49XNEK+MvIf9nIO7ZZaZdbnfWKHnXAgufpxqfA0YryH8XToi4+CjBgVnFTSQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.9.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", - "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", - "dev": true, - "dependencies": { - "@babel/core": "^7.23.5", - "@babel/plugin-transform-react-jsx-self": "^7.23.3", - "@babel/plugin-transform-react-jsx-source": "^7.23.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0" - } - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.1.tgz", - "integrity": "sha512-+LV37nQcd1EpFalkXksWNBiA17NZ5m5/WspmHGmZmdx1qBOg/VNq/c4eRJiA9VQQHBOs+N0ZhhdU10h2TyNK7Q==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001620", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz", - "integrity": "sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.4.774", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.774.tgz", - "integrity": "sha512-132O1XCd7zcTkzS3FgkAzKmnBuNJjK8WjcTtNuoylj7MYbqw5eXehjQ5OK91g0zm7OTKIPeaAG4CPoRfD9M1Mg==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" - } - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.7.tgz", - "integrity": "sha512-yrj+KInFmwuQS2UQcg1SF83ha1tuHC1jMQbRNyuWtlEzzKRDgAl7L4Yp4NlDUZTZNlWvHEzOtJhMi40R7JxcSw==", - "dev": true, - "peerDependencies": { - "eslint": ">=7" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz", - "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", - "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-icons": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", - "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "6.23.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", - "integrity": "sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ==", - "dependencies": { - "@remix-run/router": "1.16.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.23.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.1.tgz", - "integrity": "sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ==", - "dependencies": { - "@remix-run/router": "1.16.1", - "react-router": "6.23.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", - "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.5" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.17.2", - "@rollup/rollup-android-arm64": "4.17.2", - "@rollup/rollup-darwin-arm64": "4.17.2", - "@rollup/rollup-darwin-x64": "4.17.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", - "@rollup/rollup-linux-arm-musleabihf": "4.17.2", - "@rollup/rollup-linux-arm64-gnu": "4.17.2", - "@rollup/rollup-linux-arm64-musl": "4.17.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", - "@rollup/rollup-linux-riscv64-gnu": "4.17.2", - "@rollup/rollup-linux-s390x-gnu": "4.17.2", - "@rollup/rollup-linux-x64-gnu": "4.17.2", - "@rollup/rollup-linux-x64-musl": "4.17.2", - "@rollup/rollup-win32-arm64-msvc": "4.17.2", - "@rollup/rollup-win32-ia32-msvc": "4.17.2", - "@rollup/rollup-win32-x64-msvc": "4.17.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.3.15", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", - "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.6", - "minimatch": "^9.0.1", - "minipass": "^7.0.4", - "path-scurry": "^1.11.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", - "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", - "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", - "dev": true, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", - "dev": true, - "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/yaml": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", - "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==", - "dev": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 71b21d4..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, - "dependencies": { - "axios": "^1.7.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^5.2.1", - "react-router-dom": "^6.23.1" - }, - "devDependencies": { - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", - "@typescript-eslint/eslint-plugin": "^7.2.0", - "@typescript-eslint/parser": "^7.2.0", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.6", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.3", - "typescript": "^5.2.2", - "vite": "^5.2.0" - } -} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index bf170c9..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// import "./App.css"; -import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { Simulation } from "./pages/Simulation"; -import { Game } from "./pages/Game"; -import { Footer, Navbar } from "./components"; -import { Home } from "./pages/Home"; - -function App() { - return ( - - - - } /> - } /> - } /> - -