refactor: add typescript & pnpm workspace & docker file

This commit is contained in:
Ralex
2025-10-16 23:12:40 +02:00
parent 8f73241f34
commit edb7146d6d
122 changed files with 7568 additions and 8502 deletions

1
packages/common/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -0,0 +1,13 @@
{
"name": "@rahoot/common",
"version": "1.0.0",
"type": "module",
"dependencies": {
"socket.io": "^4.8.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,30 @@
export type Player = {
id: string
username: string
points: number
}
export type Answer = {
playerId: string
answerId: number
points: number
}
export type Quizz = {
subject: string
questions: {
question: string
image?: string
answers: string[]
solution: number
cooldown: number
time: number
}[]
}
export type QuizzWithId = Quizz & { id: string }
export type GameUpdateQuestion = {
current: number
total: number
}

View File

@@ -0,0 +1,72 @@
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
import { Player, QuizzWithId } from "."
import { Status, StatusDataMap } from "./status"
export type Server = ServerIO<ClientToServerEvents, ServerToClientEvents>
export type Socket = SocketIO<ClientToServerEvents, ServerToClientEvents>
export type Message<K extends keyof StatusDataMap = keyof StatusDataMap> = {
gameId?: string
status: K
data: StatusDataMap[K]
}
export type MessageWithoutStatus<T = any> = {
gameId?: string
data: T
}
export type MessageGameId = {
gameId?: string
}
export interface ServerToClientEvents {
// Game events
"game:status": (data: { name: Status; data: StatusDataMap[Status] }) => void
"game:successRoom": (data: string) => void
"game:successJoin": (gameId: string) => void
"game:totalPlayers": (count: number) => void
"game:errorMessage": (message: string) => void
"game:startCooldown": () => void
"game:cooldown": (count: number) => void
"game:kick": () => void
"game:reset": () => void
"game:updateQuestion": (data: { current: number; total: number }) => void
"game:playerAnswer": (count: number) => void
"player:updateLeaderboard": (data: { leaderboard: Player[] }) => void
// Manager events
"manager:quizzList": (quizzList: QuizzWithId[]) => void
"manager:gameCreated": (data: { gameId: string; inviteCode: string }) => void
"manager:statusUpdate": (data: {
status: Status
data: StatusDataMap[Status]
}) => void
"manager:newPlayer": (player: Player) => void
"manager:removePlayer": (playerId: string) => void
"manager:errorMessage": (message: string) => void
"manager:playerKicked": (playerId: string) => void
}
export interface ClientToServerEvents {
// Manager actions
"game:create": (quizzId: string) => void
"manager:auth": (password: string) => void
"manager:kickPlayer": (
message: MessageWithoutStatus<{ playerId: string }>
) => void
"manager:startGame": (message: MessageGameId) => void
"manager:abortQuiz": (message: MessageGameId) => void
"manager:nextQuestion": (message: MessageGameId) => void
"manager:showLeaderboard": (message: MessageGameId) => void
// Player actions
"player:join": (inviteCode: string) => void
"player:login": (message: MessageWithoutStatus<{ username: string }>) => void
"player:selectedAnswer": (
message: MessageWithoutStatus<{ answerKey: number }>
) => void
// Common
disconnect: () => void
}

View File

@@ -0,0 +1,58 @@
import { Player } from ".";
export enum Status {
SHOW_ROOM = "SHOW_ROOM",
SHOW_START = "SHOW_START",
SHOW_PREPARED = "SHOW_PREPARED",
SHOW_QUESTION = "SHOW_QUESTION",
SELECT_ANSWER = "SELECT_ANSWER",
SHOW_RESULT = "SHOW_RESULT",
SHOW_RESPONSES = "SHOW_RESPONSES",
SHOW_LEADERBOARD = "SHOW_LEADERBOARD",
FINISHED = "FINISHED",
WAIT = "WAIT",
}
export type CommonStatusDataMap = {
SHOW_START: { time: number; subject: string }
SHOW_PREPARED: { totalAnswers: number; questionNumber: number }
SHOW_QUESTION: { question: string; image?: string; cooldown: number }
SELECT_ANSWER: {
question: string
answers: string[]
image?: string
time: number
totalPlayer: number
}
SHOW_RESULT: {
correct: boolean
message: string
points: number
myPoints: number
rank: number
aheadOfMe: string | null
}
WAIT: { text: string }
FINISHED: { subject: string; top: Player[] }
}
type ManagerExtraStatus = {
SHOW_ROOM: { text: string; inviteCode?: string }
SHOW_RESPONSES: {
question: string
responses: Record<number, number>
correct: number
answers: string[]
image?: string
}
SHOW_LEADERBOARD: { leaderboard: Player[] }
}
type PlayerExtraStatus = {
WAIT: { text: string }
}
export type PlayerStatusDataMap = CommonStatusDataMap & PlayerExtraStatus
export type ManagerStatusDataMap = CommonStatusDataMap & ManagerExtraStatus
export type StatusDataMap = PlayerStatusDataMap & ManagerStatusDataMap

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}

2
packages/socket/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1,19 @@
import esbuild from "esbuild"
import path from "path"
export const config = {
entryPoints: ["src/index.ts"],
bundle: true,
minify: true,
platform: "node",
outfile: "dist/index.cjs",
sourcemap: true,
define: {
"process.env.NODE_ENV": '"production"',
},
alias: {
"@": path.resolve("./src"),
},
}
esbuild.build(config)

View File

@@ -0,0 +1,24 @@
{
"name": "@rahoot/socket",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "node esbuild.config.js",
"start": "node dist/index.cjs"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.11"
},
"devDependencies": {
"esbuild": "^0.25.10",
"tsx": "^4.20.6"
}
}

View File

@@ -0,0 +1,14 @@
import { createEnv } from "@t3-oss/env-core"
import { z } from "zod/v4"
const env = createEnv({
server: {
SOCKER_PORT: z.string().optional().default("3001"),
},
runtimeEnv: {
SOCKER_PORT: process.env.SOCKER_PORT,
},
})
export default env

View File

@@ -0,0 +1,159 @@
import { Server, Socket } from "@rahoot/common/types/game/socket"
import env from "@rahoot/socket/env"
import Config from "@rahoot/socket/services/config"
import Game from "@rahoot/socket/services/game"
import { inviteCodeValidator } from "@rahoot/socket/utils/validator"
import { Server as ServerIO } from "socket.io"
const io: Server = new ServerIO()
Config.init()
let games: Game[] = []
const port = env.SOCKER_PORT || 3001
console.log(`Socket server running on port ${port}`)
io.listen(Number(port))
function withGame<T>(
gameId: string | undefined,
socket: Socket,
games: Game[],
handler: (game: Game) => T
): T | void {
let game = null
if (gameId) {
game = games.find((g) => g.gameId === gameId)
} else {
game = games.find(
(g) =>
g.players.find((p) => p.id === socket.id) || g.managerId === socket.id
)
}
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
return handler(game)
}
io.on("connection", (socket) => {
console.log(`A user connected ${socket.id}`)
console.log(socket.handshake.auth)
socket.on("manager:auth", (password) => {
try {
const config = Config.game()
if (password !== config.managerPassword) {
socket.emit("manager:errorMessage", "Invalid password")
return
}
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to read game config:", error)
socket.emit("manager:errorMessage", "Failed to read game config")
}
})
socket.on("game:create", (quizzId) => {
const quizzList = Config.quizz()
const quizz = quizzList.find((q) => q.id === quizzId)
if (!quizz) {
socket.emit("game:errorMessage", "Quizz not found")
return
}
const game = new Game(io, socket, quizz)
games.push(game)
})
socket.on("player:join", async (inviteCode) => {
const result = inviteCodeValidator.safeParse(inviteCode)
if (result.error) {
socket.emit("game:errorMessage", result.error.issues[0].message)
return
}
const game = games.find((g) => g.inviteCode === inviteCode)
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
socket.emit("game:successRoom", game.gameId)
})
socket.on("player:login", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) => game.join(socket, data.username))
)
socket.on("manager:kickPlayer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.kickPlayer(socket, data.playerId)
)
)
socket.on("manager:startGame", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.start(socket))
)
socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.selectAnswer(socket, data.answerKey)
)
)
socket.on("manager:abortQuiz", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.abortRound(socket))
)
socket.on("manager:nextQuestion", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.nextRound(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.showLeaderboard(socket))
)
socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`)
const managerGame = games.find((g) => g.managerId === socket.id)
if (managerGame) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset")
games = games.filter((g) => g.gameId !== managerGame.gameId)
return
}
const game = games.find((g) => g.players.some((p) => p.id === socket.id))
if (game) {
const player = game.players.find((p) => p.id === socket.id)
if (player) {
game.players = game.players.filter((p) => p.id !== socket.id)
io.to(game.managerId).emit("manager:removePlayer", player.id)
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
console.log(
`Removed player ${player.username} from game ${game.gameId}`
)
}
}
})
})

View File

@@ -0,0 +1,110 @@
import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const getPath = (path: string) => resolve(process.cwd(), "../../config", path)
class Config {
static init() {
const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) {
fs.writeFileSync(
getPath("game.json"),
JSON.stringify(
{
managerPassword: "PASSWORD",
music: true,
},
null,
2
)
)
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
fs.writeFileSync(
getPath("quizz/example.json"),
JSON.stringify(
{
subject: "Example Quizz",
questions: [
{
question: "What is good answer ?",
answers: ["No", "Good answer", "No", "No"],
solution: 1,
cooldown: 5,
time: 15,
},
{
question: "What is good answer with image ?",
answers: ["No", "No", "No", "Good answer"],
image: "https://placehold.co/600x400.png",
solution: 3,
cooldown: 5,
time: 20,
},
{
question: "What is good answer with two answers ?",
answers: ["Good answer", "No"],
image: "https://placehold.co/600x400.png",
solution: 0,
cooldown: 5,
time: 20,
},
],
},
null,
2
)
)
}
}
static game() {
const isExists = fs.existsSync(getPath("game.json"))
if (!isExists) {
throw new Error("Game config not found")
}
try {
const config = fs.readFileSync(getPath("game.json"), "utf-8")
return JSON.parse(config)
} catch (error) {
console.error("Failed to read game config:", error)
}
}
static quizz() {
const isExists = fs.existsSync(getPath("quizz"))
if (!isExists) {
return []
}
try {
const files = fs
.readdirSync(getPath("quizz"))
.filter((file) => file.endsWith(".json"))
const quizz: QuizzWithId[] = files.map((file) => {
const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8")
const config = JSON.parse(data)
const id = file.replace(".json", "")
return {
id,
...config,
}
})
return quizz || []
} catch (error) {
console.error("Failed to read quizz config:", error)
return []
}
}
}
export default Config

View File

@@ -0,0 +1,384 @@
import { Answer, Player, Quizz } from "@rahoot/common/types/game"
import { Server, Socket } from "@rahoot/common/types/game/socket"
import { Status } from "@rahoot/common/types/game/status"
import createInviteCode from "@rahoot/socket/utils/inviteCode"
import { v4 as uuid } from "uuid"
import sleep from "../utils/sleep"
class Game {
io: Server
gameId: string
managerId: string
inviteCode: string
started: boolean
status: Status
quizz: Quizz
players: Player[]
round: {
currentQuestion: number
playersAnswers: Answer[]
startTime: number
}
cooldown: {
active: boolean
ms: number
}
constructor(io: Server, socket: Socket, quizz: Quizz) {
if (!io) {
throw new Error("Socket server not initialized")
}
this.io = io
this.gameId = uuid()
this.managerId = ""
this.inviteCode = ""
this.started = false
this.status = Status.SHOW_START
this.players = []
this.round = {
playersAnswers: [],
currentQuestion: 0,
startTime: 0,
}
this.cooldown = {
active: false,
ms: 0,
}
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.managerId = socket.id
this.quizz = quizz
socket.join(this.gameId)
socket.emit("manager:gameCreated", {
gameId: this.gameId,
inviteCode: roomInvite,
})
console.log(
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
)
}
join(socket: Socket, username: string) {
socket.join(this.gameId)
const playerData = {
id: socket.id,
username: username,
points: 0,
}
this.players.push(playerData)
this.io.to(this.managerId).emit("manager:newPlayer", playerData)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
socket.emit("game:successJoin", this.gameId)
}
kickPlayer(socket: Socket, playerId: string) {
if (this.managerId !== socket.id) {
return
}
const player = this.players.find((p) => p.id === playerId)
if (!player) {
return
}
this.players = this.players.filter((p) => p.id !== playerId)
this.io.in(playerId).socketsLeave(this.gameId)
this.io.to(player.id).emit("game:kick")
this.io.to(this.managerId).emit("manager:playerKicked", player.id)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
}
async startCooldown(seconds: number) {
if (this.cooldown.active) {
return
}
this.cooldown.active = true
let count = seconds - 1
return new Promise<void>((resolve) => {
const cooldownTimeout = setInterval(() => {
if (!this.cooldown.active || count <= 0) {
this.cooldown.active = false
clearInterval(cooldownTimeout)
resolve()
} else {
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
}
}, 1000)
})
}
async abortCooldown() {
if (this.cooldown.active) {
this.cooldown.active = false
}
}
async start(socket: Socket) {
if (this.managerId !== socket.id) {
return
}
if (this.started) {
return
}
this.started = true
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_START,
data: {
time: 3,
subject: this.quizz.subject,
},
})
await sleep(3)
this.io.to(this.gameId).emit("game:startCooldown")
await this.startCooldown(3)
this.newRound()
}
async newRound() {
const question = this.quizz.questions[this.round.currentQuestion]
if (!this.started) {
return
}
this.io.to(this.gameId).emit("game:updateQuestion", {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
})
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_PREPARED,
data: {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
},
})
await sleep(2)
if (!this.started) {
return
}
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_QUESTION,
data: {
question: question.question,
image: question.image,
cooldown: question.cooldown,
},
})
await sleep(question.cooldown)
if (!this.started) {
return
}
this.round.startTime = Date.now()
this.io.to(this.gameId).emit("game:status", {
name: Status.SELECT_ANSWER,
data: {
question: question.question,
answers: question.answers,
image: question.image,
time: question.time,
totalPlayer: this.players.length,
},
})
await this.startCooldown(question.time)
if (!this.started) {
return
}
this.players = this.players.map((player) => {
const playerAnswer = this.round.playersAnswers.find(
(a) => a.playerId === player.id
)
const isCorrect = playerAnswer
? playerAnswer.answerId === question.solution
: false
const points =
playerAnswer && isCorrect
? Math.round(playerAnswer && playerAnswer.points)
: 0
player.points += points
const sortPlayers = this.players.sort((a, b) => b.points - a.points)
const rank = sortPlayers.findIndex((p) => p.id === player.id) + 1
const aheadPlayer = sortPlayers[rank - 2]
this.io.to(player.id).emit("game:status", {
name: Status.SHOW_RESULT,
data: {
correct: isCorrect,
message: isCorrect ? "Nice !" : "Too bad",
points,
myPoints: player.points,
rank,
aheadOfMe: aheadPlayer ? aheadPlayer.username : null,
},
})
return player
})
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
return acc
},
{}
)
// Manager
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_RESPONSES,
data: {
question: question.question,
responses: totalType,
correct: question.solution,
answers: question.answers,
image: question.image,
},
})
this.round.playersAnswers = []
}
timeToPoint(startTime: number, secondes: number) {
let points = 1000
const actualTime = Date.now()
const tempsPasseEnSecondes = (actualTime - startTime) / 1000
points -= (1000 / secondes) * tempsPasseEnSecondes
points = Math.max(0, points)
return points
}
async selectAnswer(socket: Socket, answerId: number) {
const player = this.players.find((player) => player.id === socket.id)
const question = this.quizz.questions[this.round.currentQuestion]
if (!player) {
return
}
if (this.round.playersAnswers.find((p) => p.playerId === socket.id)) {
return
}
this.round.playersAnswers.push({
playerId: player.id,
answerId,
points: this.timeToPoint(this.round.startTime, question.time),
})
socket.emit("game:status", {
name: Status.WAIT,
data: { text: "Waiting for the players to answer" },
})
socket
.to(this.gameId)
.emit("game:playerAnswer", this.round.playersAnswers.length)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
if (this.round.playersAnswers.length === this.players.length) {
this.abortCooldown()
}
}
nextRound(socket: Socket) {
if (!this.started) {
return
}
if (socket.id !== this.managerId) {
return
}
if (!this.quizz.questions[this.round.currentQuestion + 1]) {
return
}
this.round.currentQuestion += 1
this.newRound()
}
abortRound(socket: Socket) {
if (!this.started) {
return
}
if (socket.id !== this.managerId) {
return
}
this.abortCooldown()
}
showLeaderboard(socket: Socket) {
const isLastRound =
this.round.currentQuestion + 1 === this.quizz.questions.length
const sortedPlayers = this.players.sort((a, b) => b.points - a.points)
if (isLastRound) {
socket.emit("game:status", {
name: Status.FINISHED,
data: {
subject: this.quizz.subject,
top: sortedPlayers.slice(0, 3),
},
})
return
}
socket.emit("game:status", {
name: Status.SHOW_LEADERBOARD,
data: {
leaderboard: sortedPlayers.slice(0, 5),
},
})
}
}
export default Game

View File

@@ -0,0 +1,14 @@
const createInviteCode = (length = 6) => {
let result = ""
const characters = "0123456789"
const charactersLength = characters.length
for (let i = 0; i < length; i += 1) {
const randomIndex = Math.floor(Math.random() * charactersLength)
result += characters.charAt(randomIndex)
}
return result
}
export default createInviteCode

View File

@@ -0,0 +1,4 @@
export const sleep = (sec: number) =>
new Promise((r) => void setTimeout(r, sec * 1000))
export default sleep

View File

@@ -0,0 +1,8 @@
import z from "zod"
export const usernameValidator = z
.string()
.min(4, "Username cannot be less than 4 characters")
.max(20, "Username cannot exceed 20 characters")
export const inviteCodeValidator = z.string().length(6, "Invalid invite code")

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}

42
packages/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}

36
packages/web/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,226 @@
import nextPlugin from "@next/eslint-plugin-next"
import tsEslintPlugin from "@typescript-eslint/eslint-plugin"
import tsEslintParser from "@typescript-eslint/parser"
import reactPlugin from "eslint-plugin-react"
import reactHooksPlugin from "eslint-plugin-react-hooks"
import { defineConfig } from "eslint/config"
import globals from "globals"
export default defineConfig([
{
ignores: ["**/node_modules/**", "**/.next/**"],
},
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
parser: tsEslintParser,
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
"@typescript-eslint": tsEslintPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
"@next/next": nextPlugin,
},
settings: {
react: {
version: "detect",
},
},
rules: {
"array-callback-return": [
"error",
{ allowImplicit: false, checkForEach: true, allowVoid: true },
],
"no-await-in-loop": "error",
"no-constant-binary-expression": "error",
"no-constructor-return": "error",
"no-duplicate-imports": ["error", { includeExports: true }],
"no-new-native-nonconstructor": "error",
"no-promise-executor-return": ["error", { allowVoid: true }],
"no-self-compare": "error",
"no-template-curly-in-string": "error",
"no-unmodified-loop-condition": "error",
"no-unreachable-loop": "error",
"no-unused-private-class-members": "error",
"arrow-body-style": ["error", "as-needed"],
camelcase: [
"error",
{
properties: "always",
ignoreDestructuring: true,
ignoreImports: true,
ignoreGlobals: true,
},
],
"capitalized-comments": [
"error",
"always",
{ ignoreConsecutiveComments: true },
],
"class-methods-use-this": ["error", { enforceForClassFields: true }],
complexity: ["warn", 40],
"consistent-return": "error",
curly: ["error", "all"],
"default-param-last": "error",
"dot-notation": "error",
eqeqeq: ["error", "always"],
"func-name-matching": "error",
"func-names": "error",
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
"grouped-accessor-pairs": ["error", "getBeforeSet"],
"guard-for-in": "error",
"init-declarations": ["error", "always"],
"logical-assignment-operators": [
"error",
"always",
{ enforceForIfStatements: true },
],
"max-classes-per-file": ["error", { ignoreExpressions: true }],
"max-depth": ["error", 3],
"max-lines": [
"error",
{ max: 500, skipBlankLines: true, skipComments: true },
],
"max-nested-callbacks": ["error", 3],
"max-params": ["error", 4],
"multiline-comment-style": ["error", "separate-lines"],
"no-alert": "error",
"no-bitwise": "error",
"no-caller": "error",
"no-else-return": "error",
"no-empty-function": "error",
"no-empty-static-block": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-label": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "error",
"no-invalid-this": "error",
"no-iterator": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-multi-assign": "error",
"no-multi-str": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-object-constructor": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-plusplus": "error",
"no-proto": "error",
"no-return-assign": ["error", "always"],
"no-script-url": "error",
"no-sequences": "error",
"no-throw-literal": "error",
"no-undef-init": "error",
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
"no-unused-expressions": ["error", { enforceForJSX: true }],
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-useless-call": "error",
"no-useless-computed-key": ["error", { enforceForClassMembers: true }],
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-warning-comments": ["error", { terms: ["todo"] }],
"object-shorthand": ["error", "always"],
"one-var": ["error", "never"],
"operator-assignment": ["error", "always"],
"prefer-arrow-callback": "error",
"prefer-const": [
"error",
{ destructuring: "any", ignoreReadBeforeAssign: false },
],
"prefer-destructuring": "error",
"prefer-exponentiation-operator": "error",
"prefer-numeric-literals": "error",
"prefer-object-has-own": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
radix: "error",
"require-await": "error",
"require-unicode-regexp": "error",
"symbol-description": "error",
yoda: "error",
"line-comment-position": ["error", { position: "above" }],
indent: "off",
"newline-before-return": "error",
"no-undef": "error",
"padded-blocks": ["error", "never"],
"padding-line-between-statements": [
"error",
{
blankLine: "always",
prev: "*",
next: [
"break",
"case",
"cjs-export",
"class",
"continue",
"do",
"if",
"switch",
"try",
"while",
"return",
],
},
{
blankLine: "always",
prev: [
"break",
"case",
"cjs-export",
"class",
"continue",
"do",
"if",
"switch",
"try",
"while",
"return",
],
next: "*",
},
],
quotes: [
"error",
"double",
{ avoidEscape: true, allowTemplateLiterals: true },
],
"space-before-blocks": "error",
semi: ["error", "never"],
// Extra rules
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": ["error", { forbid: [">", "}"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "off",
},
},
])

View File

@@ -0,0 +1,10 @@
const nextConfig = {
output: "standalone",
productionBrowserSourceMaps: false,
transpilePackages: ["packages/*", "@t3-oss/env-nextjs"],
eslint: {
ignoreDuringBuilds: true,
},
}
export default nextConfig

45
packages/web/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@rahoot/web",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node .next/standalone/packages/web/server.js",
"lint": "eslint"
},
"dependencies": {
"@rahoot/common": "workspace:*",
"@rahoot/socket": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"clsx": "^2.1.1",
"ky": "^1.11.0",
"next": "15.5.4",
"react": "19.1.0",
"react-confetti": "^6.4.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"socket.io-client": "^4.8.1",
"use-sound": "^5.0.0",
"yup": "^1.7.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.1.1",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="100%" height="100%" viewBox="5.989999771118164 -63.970001220703125 88.26000213623047 65.80000305175781"><g fill="#ff9900"><path d="M65.97 -41.84C65.97 -45.17 65.2 -48.32 63.68 -51.28C62.15 -54.25 59.98 -56.62 57.15 -58.4C55.98 -59.12 54.67 -59.7 53.2 -60.14C51.73 -60.59 50.2 -60.93 48.62 -61.18C47.04 -61.43 45.48 -61.59 43.92 -61.64C42.37 -61.7 40.93 -61.72 39.6 -61.72C34.38 -61.72 29.18 -61.56 24 -61.23C18.81 -60.89 13.61 -60.59 8.4 -60.31C8.07 -51.33 7.82 -42.34 7.65 -33.36C7.6 -28.31 7.4 -23.25 7.07 -18.18C6.74 -13.1 6.38 -8.01 5.99 -2.91C7.54 -2.75 9.08 -2.63 10.61 -2.58C12.13 -2.52 13.7 -2.5 15.31 -2.5C18.47 -2.5 21.63 -2.58 24.79 -2.75L24.37 -21.63C26.15 -21.74 27.94 -21.88 29.74 -22.04C31.54 -22.21 33.36 -22.35 35.19 -22.46C36.8 -18.3 38.04 -14.34 38.93 -10.56C39.82 -6.79 40.48 -2.66 40.93 1.83L65.22 1.25C61.11 -8.35 56.32 -17.72 50.83 -26.87C55.37 -27.09 59.04 -28.51 61.81 -31.11C64.58 -33.72 65.97 -37.3 65.97 -41.84ZM46.67 -43.17C46.67 -42.9 46.65 -42.43 46.63 -41.76C46.6 -41.09 46.57 -40.42 46.54 -39.72C46.52 -39.03 46.45 -38.36 46.34 -37.73C46.22 -37.09 46.09 -36.66 45.92 -36.44C45.53 -35.94 44.85 -35.53 43.88 -35.23C42.91 -34.92 41.86 -34.69 40.72 -34.52C39.58 -34.36 38.47 -34.25 37.39 -34.19C36.31 -34.13 35.49 -34.11 34.94 -34.11C33.66 -34.11 32.36 -34.16 31.03 -34.27C29.7 -34.38 28.42 -34.58 27.2 -34.86L24.96 -50.99C27.06 -51.33 29.18 -51.6 31.32 -51.83C33.46 -52.05 35.63 -52.16 37.85 -52.16C38.13 -52.16 38.61 -52.14 39.31 -52.12C40 -52.09 40.72 -52.03 41.47 -51.95C42.22 -51.87 42.92 -51.77 43.59 -51.66C44.26 -51.55 44.73 -51.38 45 -51.16C45.39 -50.83 45.7 -50.31 45.92 -49.62C46.14 -48.93 46.31 -48.18 46.42 -47.38C46.53 -46.57 46.6 -45.78 46.63 -45C46.65 -44.23 46.67 -43.62 46.67 -43.17Z M94.25 -63.97L74.87 -62.39C75.09 -58.56 75.28 -54.75 75.45 -50.95C75.62 -47.15 75.89 -43.37 76.28 -39.6L78.36 -18.63L87.51 -19.47C88.34 -23.63 89.19 -27.73 90.05 -31.78C90.91 -35.83 91.76 -39.93 92.59 -44.09C92.86 -45.53 93.1 -47.1 93.29 -48.79C93.49 -50.48 93.64 -52.21 93.75 -53.99C93.86 -55.76 93.96 -57.51 94.04 -59.23C94.13 -60.95 94.2 -62.53 94.25 -63.97ZM92.75 -6.65C92.75 -6.88 92.74 -7.08 92.71 -7.28C92.69 -7.47 92.62 -7.68 92.5 -7.9C91.89 -9.34 90.97 -10.43 89.72 -11.15C88.47 -11.87 87.07 -12.23 85.52 -12.23C84.57 -12.23 83.56 -12.08 82.48 -11.77C81.4 -11.47 80.4 -11.02 79.49 -10.44C78.57 -9.86 77.79 -9.14 77.16 -8.28C76.52 -7.42 76.2 -6.46 76.2 -5.41C76.2 -4.41 76.44 -3.52 76.91 -2.75C77.38 -1.97 77.99 -1.32 78.74 -0.79C79.49 -0.26 80.32 0.15 81.23 0.46C82.15 0.76 83.02 0.92 83.85 0.92C84.74 0.92 85.71 0.71 86.76 0.29C87.82 -0.12 88.79 -0.69 89.68 -1.41C90.56 -2.14 91.3 -2.95 91.88 -3.87C92.46 -4.78 92.75 -5.71 92.75 -6.65Z"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,17 @@
"use client"
import { useSocket } from "@rahoot/web/contexts/socketProvider"
import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => {
const { isConnected, connect } = useSocket()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
return children
}
export default AuthLayout

View File

@@ -0,0 +1,43 @@
"use client"
import { QuizzWithId } from "@rahoot/common/types/game"
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerGameStore } from "@rahoot/web/stores/game"
import { useRouter } from "next/navigation"
import { useState } from "react"
export default function Manager() {
const { setStatus } = useManagerGameStore()
const router = useRouter()
const { socket } = useSocket()
const [isAuth, setIsAuth] = useState(false)
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true)
setQuizzList(quizzList)
})
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
setStatus("SHOW_ROOM", { text: "Waiting for the players", inviteCode })
router.push(`/game/manager/${gameId}`)
})
const handleAuth = (password: string) => {
socket?.emit("manager:auth", password)
}
const handleCreate = (quizzId: string) => {
console.log(quizzId)
socket?.emit("game:create", quizzId)
console.log("create room")
}
if (!isAuth) {
return <ManagerPassword onSubmit={handleAuth} />
}
return <SelectQuizz quizzList={quizzList} onSelect={handleCreate} />
}

View File

@@ -0,0 +1,38 @@
"use client"
import logo from "@rahoot/web/assets/logo.svg"
import Room from "@rahoot/web/components/game/join/Room"
import Username from "@rahoot/web/components/game/join/Username"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import Image from "next/image"
import { useEffect } from "react"
import toast from "react-hot-toast"
export default function Home() {
const { isConnected, connect } = useSocket()
const { player } = usePlayerStore()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEvent("game:errorMessage", (message) => {
toast.error(message)
})
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
{!player ? <Room /> : <Username />}
</section>
)
}

View File

@@ -0,0 +1,78 @@
"use client"
import { Status } from "@rahoot/common/types/game/status"
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
import Answers from "@rahoot/web/components/game/states/Answers"
import Prepared from "@rahoot/web/components/game/states/Prepared"
import Question from "@rahoot/web/components/game/states/Question"
import Result from "@rahoot/web/components/game/states/Result"
import Start from "@rahoot/web/components/game/states/Start"
import Wait from "@rahoot/web/components/game/states/Wait"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import { usePlayerGameStore } from "@rahoot/web/stores/game"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import toast from "react-hot-toast"
export default function Game() {
const router = useRouter()
const { player, logout } = usePlayerStore()
const { status, setStatus, resetStatus } = usePlayerGameStore()
useEffect(() => {
if (!player) {
router.replace("/")
}
}, [player, router])
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS) {
setStatus(name, data)
}
})
useEvent("game:reset", () => {
router.replace("/")
logout()
resetStatus()
toast("The game has been reset by the host")
})
let component = null
switch (status.name) {
case Status.WAIT:
component = <Wait data={status.data} />
break
case Status.SHOW_START:
component = <Start data={status.data} />
break
case Status.SHOW_PREPARED:
component = <Prepared data={status.data} />
break
case Status.SHOW_QUESTION:
component = <Question data={status.data} />
break
case Status.SHOW_RESULT:
component = <Result data={status.data} />
break
case Status.SELECT_ANSWER:
component = <Answers data={status.data} />
break
}
return <GameWrapper>{component}</GameWrapper>
}

View File

@@ -0,0 +1,17 @@
"use client"
import { useSocket } from "@rahoot/web/contexts/socketProvider"
import { PropsWithChildren, useEffect } from "react"
const GameLayout = ({ children }: PropsWithChildren) => {
const { isConnected, connect } = useSocket()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
return children
}
export default GameLayout

View File

@@ -0,0 +1,112 @@
"use client"
import { Status } from "@rahoot/common/types/game/status"
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
import Answers from "@rahoot/web/components/game/states/Answers"
import Leaderboard from "@rahoot/web/components/game/states/Leaderboard"
import Podium from "@rahoot/web/components/game/states/Podium"
import Prepared from "@rahoot/web/components/game/states/Prepared"
import Question from "@rahoot/web/components/game/states/Question"
import Responses from "@rahoot/web/components/game/states/Responses"
import Room from "@rahoot/web/components/game/states/Room"
import Start from "@rahoot/web/components/game/states/Start"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerGameStore } from "@rahoot/web/stores/game"
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
export default function ManagerGame() {
const { socket } = useSocket()
const [nextText, setNextText] = useState("Start")
const { status, setStatus } = useManagerGameStore()
const { gameId }: { gameId?: string } = useParams()
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS_MANAGER) {
setStatus(name, data)
}
})
useEffect(() => {
if (status.name === "SHOW_START") {
setNextText("Start")
}
}, [status.name])
const handleSkip = () => {
setNextText("Skip")
switch (status.name) {
case Status.SHOW_ROOM:
socket?.emit("manager:startGame", { gameId })
break
case Status.SELECT_ANSWER:
socket?.emit("manager:abortQuiz", { gameId })
break
case Status.SHOW_RESPONSES:
socket?.emit("manager:showLeaderboard", { gameId })
break
case Status.SHOW_LEADERBOARD:
socket?.emit("manager:nextQuestion", { gameId })
break
}
}
let component = null
switch (status.name) {
case Status.SHOW_ROOM:
component = <Room data={status.data} />
break
case Status.SHOW_START:
component = <Start data={status.data} />
break
case Status.SHOW_PREPARED:
component = <Prepared data={status.data} />
break
case Status.SHOW_QUESTION:
component = <Question data={status.data} />
break
case Status.SELECT_ANSWER:
component = <Answers data={status.data} />
break
case Status.SHOW_RESPONSES:
component = <Responses data={status.data} />
break
case Status.SHOW_LEADERBOARD:
component = <Leaderboard data={status.data} />
break
case Status.FINISHED:
component = <Podium data={status.data} />
break
}
return (
<GameWrapper textNext={nextText} onNext={handleSkip} manager>
{component}
</GameWrapper>
)
}

View File

@@ -0,0 +1,194 @@
@import "tailwindcss";
@theme {
--color-primary: #ff9900;
--color-secondary: #1a140b;
}
.btn-shadow {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
}
.btn-shadow span {
display: block;
transform: translateY(-2px);
}
.btn-shadow:hover {
box-shadow: rgba(0, 0, 0, 0.25) 0px -2px inset;
}
.btn-shadow:hover span {
transform: translateY(0);
}
.btn-shadow:active {
transform: translateY(1px);
box-shadow: none;
}
.text-outline {
-webkit-text-stroke: 2px rgba(0, 0, 0, 0.25);
}
.shadow-inset {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
}
.spotlight {
position: absolute;
height: 200%;
width: 200%;
z-index: 100;
background-image: radial-gradient(
circle,
transparent 180px,
rgba(0, 0, 0, 0.6) 200px
);
opacity: 0;
left: -50%;
top: -50%;
transition: all 0.5s;
animation: spotlightAnim 2.5s ease-in;
}
@keyframes spotlightAnim {
0% {
left: -20%;
top: -20%;
}
30% {
opacity: 100;
top: -80%;
left: -80%;
}
60% {
top: -50%;
left: -20%;
}
80% {
top: -50%;
left: -50%;
}
98% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.anim-show {
animation: show 0.5s ease-out;
}
.anim-timer {
animation: timer 1s ease-out infinite;
}
.anim-quizz {
animation: quizz 0.8s linear;
transform: perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
box-shadow: 10px 10px 0 rgba(20, 24, 29, 1);
}
.anim-quizz .button {
box-shadow: rgba(0, 0, 0, 0.25) -4px -4px inset;
animation: quizzButton 0.8s ease-out;
}
.anim-balanced {
animation: balanced 0.8s linear infinite;
}
@keyframes balanced {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(-10deg) translateY(-10px);
}
50% {
transform: rotate(0deg) translateY(0px);
}
75% {
transform: rotate(10deg) translateY(-10px);
}
100% {
transform: rotate(0deg);
}
}
@keyframes show {
0% {
transform: scale(0);
}
30% {
transform: scale(0.9);
}
60% {
transform: scale(0.8);
}
80% {
transform: scale(1);
}
}
@keyframes progressBar {
from {
width: 0%;
}
to {
width: 100%;
}
}
@keyframes timer {
0% {
transform: scale(1);
}
30% {
transform: scale(1.4) rotate(-6deg);
}
60% {
transform: scale(0.8) rotate(6deg);
}
80% {
transform: scale(1);
}
}
@keyframes quizz {
0% {
transform: scale(0) perspective(1200px) rotateY(-60deg) rotateX(60deg)
translateZ(100px);
}
60% {
transform: scale(1) perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
}
80% {
transform: scale(0.8) perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
}
100% {
transform: scale(1) perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
}
}
@keyframes quizzButton {
0% {
transform: scale(0);
}
60% {
transform: scale(1);
}
80% {
transform: scale(0.8);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,29 @@
import Toaster from "@rahoot/web/components/Toaster"
import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
import type { Metadata } from "next"
import { Montserrat } from "next/font/google"
import { PropsWithChildren } from "react"
import "./globals.css"
const montserrat = Montserrat({
variable: "--font-montserrat",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "Rahoot !",
icons: "/icon.svg",
}
export default function RootLayout({ children }: PropsWithChildren) {
return (
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
<body className={`${montserrat.variable} bg-secondary antialiased`}>
<SocketProvider>
<main className="text-base-[8px] flex flex-col">{children}</main>
<Toaster />
</SocketProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,10 @@
import env from "@rahoot/web/env"
import { NextResponse } from "next/server"
export function GET() {
return NextResponse.json({
url: env.SOCKET_URL,
})
}
export const dynamic = "force-dynamic"

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;display:block;" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" r="34" stroke="#fff4e4" stroke-width="13" stroke-linecap="square" fill="none">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;180 50 50;720 50 50" keyTimes="0;0.5;1"></animateTransform>
<animate attributeName="stroke-dasharray" repeatCount="indefinite" dur="1s" values="21.362830044410593 192.26547039969535;106.81415022205297 106.81415022205297;21.362830044410593 192.26547039969535" keyTimes="0;0.5;1"></animate>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 704 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,27 @@
import clsx from "clsx"
import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
type Props = PropsWithChildren &
ButtonHTMLAttributes<HTMLButtonElement> & {
icon: ElementType
}
export default function AnswerButton({
className,
icon: Icon,
children,
...otherProps
}: Props) {
return (
<button
className={clsx(
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left",
className,
)}
{...otherProps}
>
<Icon className="h-6 w-6" />
<span className="drop-shadow-md">{children}</span>
</button>
)
}

View File

@@ -0,0 +1,17 @@
import clsx from "clsx"
import { ButtonHTMLAttributes, PropsWithChildren } from "react"
type Props = ButtonHTMLAttributes<HTMLButtonElement> & PropsWithChildren
export default function Button({ children, className, ...otherProps }: Props) {
return (
<button
className={clsx(
"btn-shadow bg-primary rounded-md p-2 text-lg font-semibold text-white",
className,
)}
{...otherProps}
>
<span>{children}</span>
</button>
)
}

View File

@@ -0,0 +1,9 @@
import { PropsWithChildren } from "react"
export default function Form({ children }: PropsWithChildren) {
return (
<div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
{children}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import clsx from "clsx"
import React from "react"
type Props = React.InputHTMLAttributes<HTMLInputElement>
export default function Input({
className,
type = "text",
...otherProps
}: Props) {
return (
<input
type={type}
className={clsx(
"rounded-sm p-2 text-lg font-semibold outline-2 outline-gray-300",
className,
)}
{...otherProps}
/>
)
}

View File

@@ -0,0 +1,6 @@
import loader from "@rahoot/web/assets/loader.svg"
import Image from "next/image"
export default function Loader() {
return <Image alt="loader" src={loader} />
}

View File

@@ -0,0 +1,27 @@
"use client"
import { ToastBar, Toaster as ToasterRaw } from "react-hot-toast"
const Toaster = () => (
<ToasterRaw>
{(t) => (
<ToastBar
toast={t}
style={{
...t.style,
boxShadow: "rgba(0, 0, 0, 0.25) 0px -4px inset",
fontWeight: 700,
}}
>
{({ icon, message }) => (
<>
{icon}
{message}
</>
)}
</ToastBar>
)}
</ToasterRaw>
)
export default Toaster

View File

@@ -0,0 +1,87 @@
"use client"
import { GameUpdateQuestion } from "@rahoot/common/types/game"
import background from "@rahoot/web/assets/background.webp"
import Button from "@rahoot/web/components/Button"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { PropsWithChildren, useEffect, useState } from "react"
type Props = PropsWithChildren & {
textNext?: string
onNext?: () => void
manager?: boolean
}
export default function GameWrapper({
children,
textNext,
onNext,
manager,
}: Props) {
const { isConnected, connect } = useSocket()
const { player, logout } = usePlayerStore()
const router = useRouter()
const [questionState, setQuestionState] = useState<GameUpdateQuestion>()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEvent("game:kick", () => {
logout()
router.replace("/")
})
useEvent("game:updateQuestion", ({ current, total }) => {
setQuestionState({
current,
total,
})
})
return (
<section className="relative flex min-h-screen w-full flex-col justify-between">
<div className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70">
<Image
className="pointer-events-none h-full w-full object-cover opacity-60"
src={background}
alt="background"
/>
</div>
<div className="flex w-full justify-between p-4">
{questionState && (
<div className="shadow-inset flex items-center rounded-md bg-white p-2 px-4 text-lg font-bold text-black">
{`${questionState.current} / ${questionState.total}`}
</div>
)}
{manager && (
<Button
className="self-end bg-white px-4 !text-black"
onClick={onNext}
>
{textNext}
</Button>
)}
</div>
{children}
{!manager && (
<div className="z-50 flex items-center justify-between bg-white px-4 py-2 text-lg font-bold text-white">
<p className="text-gray-800">{player?.username}</p>
<div className="rounded-sm bg-gray-800 px-3 py-1 text-lg">
{player?.points}
</div>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,52 @@
import logo from "@rahoot/web/assets/logo.svg"
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { KeyboardEvent, useState } from "react"
import toast from "react-hot-toast"
type Props = {
// eslint-disable-next-line no-unused-vars
onSubmit: (password: string) => void
}
export default function ManagerPassword({ onSubmit }: Props) {
const [password, setPassword] = useState("")
const handleSubmit = () => {
onSubmit(password)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleSubmit()
}
}
useEvent("manager:errorMessage", (message) => {
toast.error(message)
})
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
<Form>
<Input
type="password"
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Manager password"
/>
<Button onClick={handleSubmit}>Submit</Button>
</Form>
</section>
)
}

View File

@@ -0,0 +1,79 @@
import { Quizz } from "@rahoot/common/types/game"
import logo from "@rahoot/web/assets/logo.svg"
import Button from "@rahoot/web/components/Button"
import clsx from "clsx"
import Image from "next/image"
import { useState } from "react"
import toast from "react-hot-toast"
type QuizzWithId = Quizz & { id: string }
type Props = {
quizzList: QuizzWithId[]
// eslint-disable-next-line no-unused-vars
onSelect: (id: string) => void
}
export default function SelectQuizz({ quizzList, onSelect }: Props) {
const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (id: string) => () => {
if (selected === id) {
setSelected(null)
} else {
setSelected(id)
}
}
const handleSubmit = () => {
if (!selected) {
toast.error("Please select a quizz")
return
}
onSelect(selected)
}
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex flex-col items-center justify-center">
<h1 className="mb-2 text-2xl font-bold">Select a quizz</h1>
<div className="w-full space-y-2">
{quizzList.map((quizz) => (
<button
key={quizz.id}
className={clsx(
"flex w-full items-center justify-between rounded-md p-3 outline outline-gray-300",
{
"border-primary outline-primary outline-2":
selected === quizz.id,
},
)}
onClick={handleSelect(quizz.id)}
>
{quizz.subject}
<div
className={clsx(
"h-4 w-4 rounded-sm outline-2 outline-gray-300",
selected === quizz.id && "bg-primary outline-primary/50",
)}
></div>
</button>
))}
</div>
</div>
<Button onClick={handleSubmit}>Submit</Button>
</div>
</section>
)
}

View File

@@ -0,0 +1,37 @@
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { KeyboardEvent, useState } from "react"
export default function Room() {
const { socket } = useSocket()
const { join } = usePlayerStore()
const [invitation, setInvitation] = useState("")
const handleJoin = () => {
socket?.emit("player:join", invitation)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleJoin()
}
}
useEvent("game:successRoom", (gameId) => {
join(gameId)
})
return (
<Form>
<Input
onChange={(e) => setInvitation(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="PIN Code here"
/>
<Button onClick={handleJoin}>Submit</Button>
</Form>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useRouter } from "next/navigation"
import { KeyboardEvent, useState } from "react"
export default function Username() {
const { socket } = useSocket()
const { player, login } = usePlayerStore()
const router = useRouter()
const [username, setUsername] = useState("")
const handleLogin = () => {
socket?.emit("player:login", { gameId: player?.gameId, data: { username } })
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleLogin()
}
}
useEvent("game:successJoin", (gameId) => {
login(username)
router.replace(`/game/${gameId}`)
})
return (
<Form>
<Input
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Username here"
/>
<Button onClick={handleLogin}>Submit</Button>
</Form>
)
}

View File

@@ -0,0 +1,114 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import {
ANSWERS_COLORS,
ANSWERS_ICONS,
SFX_ANSWERS_MUSIC,
SFX_ANSWERS_SOUND,
} from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SELECT_ANSWER"]
}
export default function Answers({
data: { question, answers, image, time, totalPlayer },
}: Props) {
const { gameId }: { gameId?: string } = useParams()
const { socket } = useSocket()
const { player } = usePlayerStore()
const [cooldown, setCooldown] = useState(time)
const [totalAnswer, setTotalAnswer] = useState(0)
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
volume: 0.1,
})
const [playMusic] = useSound(SFX_ANSWERS_MUSIC, {
volume: 0.2,
interrupt: true,
})
const handleAnswer = (answerKey: number) => () => {
if (!player) {
return
}
socket?.emit("player:selectedAnswer", {
gameId,
data: {
answerKey,
},
})
sfxPop()
}
useEffect(() => {
console.log("play music")
playMusic()
}, [])
useEvent("game:cooldown", (sec) => {
setCooldown(sec)
})
useEvent("game:playerAnswer", (count) => {
setTotalAnswer(count)
sfxPop()
})
return (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
{Boolean(image) && (
<img
alt={question}
src={image}
className="h-48 max-h-60 w-auto rounded-md"
/>
)}
</div>
<div>
<div className="mx-auto mb-4 flex w-full max-w-7xl justify-between gap-1 px-2 text-lg font-bold text-white md:text-xl">
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
<span className="translate-y-1 text-sm">Time</span>
<span>{cooldown}</span>
</div>
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
<span className="translate-y-1 text-sm">Answers</span>
<span>
{totalAnswer}/{totalPlayer}
</span>
</div>
</div>
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
{answers.map((answer, key) => (
<AnswerButton
key={key}
className={clsx(ANSWERS_COLORS[key])}
icon={ANSWERS_ICONS[key]}
onClick={handleAnswer(key)}
>
{answer}
</AnswerButton>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
type Props = {
data: ManagerStatusDataMap["SHOW_LEADERBOARD"]
}
export default function Leaderboard({ data: { leaderboard } }: Props) {
return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center px-2">
<h2 className="mb-6 text-5xl font-bold text-white drop-shadow-md">
Leaderboard
</h2>
<div className="flex w-full flex-col gap-2">
{leaderboard.map(({ username, points }, key) => (
<div
key={key}
className="bg-primary flex w-full justify-between rounded-md p-3 text-2xl font-bold text-white"
>
<span className="drop-shadow-md">{username}</span>
<span className="drop-shadow-md">{points}</span>
</div>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,204 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import useScreenSize from "@rahoot/web/hook/useScreenSize"
import {
SFX_PODIUM_FIRST,
SFX_PODIUM_SECOND,
SFX_PODIUM_THREE,
SFX_SNEAR_ROOL,
} from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useEffect, useState } from "react"
import ReactConfetti from "react-confetti"
import useSound from "use-sound"
type Props = {
data: ManagerStatusDataMap["FINISHED"]
}
export default function Podium({ data: { subject, top } }: Props) {
const [apparition, setApparition] = useState(0)
const { width, height } = useScreenSize()
const [sfxtThree] = useSound(SFX_PODIUM_THREE, {
volume: 0.2,
})
const [sfxSecond] = useSound(SFX_PODIUM_SECOND, {
volume: 0.2,
})
const [sfxRool, { stop: sfxRoolStop }] = useSound(SFX_SNEAR_ROOL, {
volume: 0.2,
})
const [sfxFirst] = useSound(SFX_PODIUM_FIRST, {
volume: 0.2,
})
useEffect(() => {
console.log(apparition)
switch (apparition) {
case 4:
sfxRoolStop()
sfxFirst()
break
case 3:
sfxRool()
break
case 2:
sfxSecond()
break
case 1:
sfxtThree()
break
}
}, [apparition, sfxFirst, sfxSecond, sfxtThree, sfxRool, sfxRoolStop])
useEffect(() => {
if (top.length < 3) {
setApparition(4)
return
}
const interval = setInterval(() => {
if (apparition > 4) {
clearInterval(interval)
return
}
setApparition((value) => value + 1)
}, 2000)
// eslint-disable-next-line consistent-return
return () => clearInterval(interval)
}, [apparition, top.length])
return (
<>
{apparition >= 4 && (
<ReactConfetti
width={width}
height={height}
className="h-full w-full"
/>
)}
{apparition >= 3 && top.length >= 3 && (
<div className="pointer-events-none absolute min-h-screen w-full overflow-hidden">
<div className="spotlight"></div>
</div>
)}
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between">
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{subject}
</h2>
<div
style={{ gridTemplateColumns: `repeat(${top.length}, 1fr)` }}
className={`grid w-full max-w-[800px] flex-1 items-end justify-center justify-self-end overflow-x-visible overflow-y-hidden`}
>
{top[1] && (
<div
className={clsx(
"z-20 flex h-[50%] w-full translate-y-full flex-col items-center justify-center gap-3 opacity-0 transition-all",
{ "!translate-y-0 opacity-100": apparition >= 2 },
)}
>
<p
className={clsx(
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
{
"anim-balanced": apparition >= 4,
},
)}
>
{top[1].username}
</p>
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-zinc-400 bg-zinc-500 text-3xl font-bold text-white drop-shadow-lg">
<span className="drop-shadow-md">2</span>
</p>
<p className="text-2xl font-bold text-white drop-shadow-lg">
{top[1].points}
</p>
</div>
</div>
)}
<div
className={clsx(
"z-30 flex h-[60%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all",
{
"!translate-y-0 opacity-100": apparition >= 3,
},
{
"md:min-w-64": top.length < 2,
},
)}
>
<p
className={clsx(
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white opacity-0 drop-shadow-lg md:text-4xl",
{ "anim-balanced opacity-100": apparition >= 4 },
)}
>
{top[0].username}
</p>
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-amber-400 bg-amber-300 text-3xl font-bold text-white drop-shadow-lg">
<span className="drop-shadow-md">1</span>
</p>
<p className="text-2xl font-bold text-white drop-shadow-lg">
{top[0].points}
</p>
</div>
</div>
{top[2] && (
<div
className={clsx(
"z-10 flex h-[40%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all",
{
"!translate-y-0 opacity-100": apparition >= 1,
},
)}
>
<p
className={clsx(
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
{
"anim-balanced": apparition >= 4,
},
)}
>
{top[2].username}
</p>
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-amber-800 bg-amber-700 text-3xl font-bold text-white drop-shadow-lg">
<span className="drop-shadow-md">3</span>
</p>
<p className="text-2xl font-bold text-white drop-shadow-lg">
{top[2].points}
</p>
</div>
</div>
)}
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,33 @@
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { ANSWERS_COLORS, ANSWERS_ICONS } from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { createElement } from "react"
type Props = {
data: CommonStatusDataMap["SHOW_PREPARED"]
}
export default function Prepared({
data: { totalAnswers, questionNumber },
}: Props) {
return (
<section className="anim-show relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
<h2 className="anim-show mb-20 text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
Question #{questionNumber}
</h2>
<div className="anim-quizz grid aspect-square w-60 grid-cols-2 gap-4 rounded-2xl bg-gray-700 p-5 md:w-60">
{[...Array(totalAnswers)].map((_, key) => (
<div
key={key}
className={clsx(
"button shadow-inset flex aspect-square h-full w-full items-center justify-center rounded-2xl",
ANSWERS_COLORS[key],
)}
>
{createElement(ANSWERS_ICONS[key], { className: "h-10 md:h-14" })}
</div>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
import { useEffect } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_QUESTION"]
}
export default function Question({
data: { question, image, cooldown },
}: Props) {
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
useEffect(() => {
sfxShow()
}, [sfxShow])
return (
<section className="relative mx-auto flex h-full w-full max-w-7xl flex-1 flex-col items-center px-4">
<div className="flex flex-1 flex-col items-center justify-center gap-5">
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
{Boolean(image) && (
<img
alt={question}
src={image}
className="h-48 max-h-60 w-auto rounded-md"
/>
)}
</div>
<div
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
style={{ animation: `progressBar ${cooldown}s linear forwards` }}
></div>
</section>
)
}

View File

@@ -0,0 +1,125 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import {
ANSWERS_COLORS,
ANSWERS_ICONS,
SFX_ANSWERS_MUSIC,
SFX_RESULTS_SOUND,
} from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useEffect, useState } from "react"
import useSound from "use-sound"
import AnswerButton from "../../AnswerButton"
const calculatePercentages = (
objectResponses: Record<string, number>,
): Record<string, string> => {
const keys = Object.keys(objectResponses)
const values = Object.values(objectResponses)
if (!values.length) {
return {}
}
const totalSum = values.reduce(
(accumulator, currentValue) => accumulator + currentValue,
0,
)
const result: Record<string, string> = {}
keys.forEach((key) => {
result[key] = `${((objectResponses[key] / totalSum) * 100).toFixed()}%`
})
return result
}
type Props = {
data: ManagerStatusDataMap["SHOW_RESPONSES"]
}
export default function Responses({
data: { question, answers, responses, correct },
}: Props) {
const [percentages, setPercentages] = useState<Record<string, string>>({})
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
})
const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, {
volume: 0.2,
onplay: () => {
setIsMusicPlaying(true)
},
onend: () => {
setIsMusicPlaying(false)
},
})
useEffect(() => {
stopMusic()
sfxResults()
setPercentages(calculatePercentages(responses))
}, [responses, playMusic, stopMusic, sfxResults])
useEffect(() => {
if (!isMusicPlaying) {
playMusic()
}
}, [isMusicPlaying, playMusic])
useEffect(() => {
stopMusic()
}, [playMusic, stopMusic])
return (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
<div
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
>
{answers.map((_, key) => (
<div
key={key}
className={clsx(
"flex flex-col justify-end self-end overflow-hidden rounded-md",
ANSWERS_COLORS[key],
)}
style={{ height: percentages[key] }}
>
<span className="w-full bg-black/10 text-center text-lg font-bold text-white drop-shadow-md">
{responses[key] || 0}
</span>
</div>
))}
</div>
</div>
<div>
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
{answers.map((answer, key) => (
<AnswerButton
key={key}
className={clsx(ANSWERS_COLORS[key], {
"opacity-65": responses && correct !== key,
})}
icon={ANSWERS_ICONS[key]}
>
{answer}
</AnswerButton>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import CricleCheck from "@rahoot/web/components/icons/CricleCheck"
import CricleXmark from "@rahoot/web/components/icons/CricleXmark"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { SFX_RESULTS_SOUND } from "@rahoot/web/utils/constants"
import { useEffect } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_RESULT"]
}
export default function Result({
data: { correct, message, points, myPoints, rank, aheadOfMe },
}: Props) {
const player = usePlayerStore()
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
})
useEffect(() => {
player.updatePoints(myPoints)
sfxResults()
}, [sfxResults])
return (
<section className="anim-show relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
{correct ? (
<CricleCheck className="aspect-square max-h-60 w-full" />
) : (
<CricleXmark className="aspect-square max-h-60 w-full" />
)}
<h2 className="mt-1 text-4xl font-bold text-white drop-shadow-lg">
{message}
</h2>
<p className="mt-1 text-xl font-bold text-white drop-shadow-lg">
{`You are top ${rank}${aheadOfMe ? `, behind ${aheadOfMe}` : ""}`}
</p>
{correct && (
<span className="mt-2 rounded bg-black/40 px-4 py-2 text-2xl font-bold text-white drop-shadow-lg">
+{points}
</span>
)}
</section>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import { Player } from "@rahoot/common/types/game"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useState } from "react"
type Props = {
data: ManagerStatusDataMap["SHOW_ROOM"]
}
export default function Room({ data: { text, inviteCode } }: Props) {
const { socket } = useSocket()
const [playerList, setPlayerList] = useState<Player[]>([])
const [totalPlayers, setTotalPlayers] = useState(0)
useEvent("manager:newPlayer", (player) => {
setPlayerList([...playerList, player])
})
useEvent("manager:removePlayer", (playerId) => {
setPlayerList(playerList.filter((p) => p.id !== playerId))
})
useEvent("manager:playerKicked", (playerId) => {
setPlayerList(playerList.filter((p) => p.id !== playerId))
})
useEvent("game:totalPlayers", (total) => {
setTotalPlayers(total)
})
const handleKick = (playerId: string) => () => {
socket?.emit("manager:kickPlayer", {
data: { playerId },
})
}
return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center px-2">
<div className="mb-10 rotate-3 rounded-md bg-white px-6 py-4 text-6xl font-extrabold">
{inviteCode}
</div>
<h2 className="mb-4 text-4xl font-bold text-white drop-shadow-lg">
{text}
</h2>
<div className="mb-6 flex items-center justify-center rounded-full bg-black/40 px-6 py-3">
<span className="text-2xl font-bold text-white drop-shadow-md">
Players Joined: {totalPlayers}
</span>
</div>
<div className="flex flex-wrap gap-3">
{playerList.map((player) => (
<div
key={player.id}
className="shadow-inset bg-primary rounded-md px-4 py-3 font-bold text-white"
onClick={handleKick(player.id)}
>
<span className="cursor-pointer text-xl drop-shadow-md hover:line-through">
{player.username}
</span>
</div>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,55 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import { SFX_BOUMP_SOUND } from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useState } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_START"]
}
export default function Start({ data: { time, subject } }: Props) {
const [showTitle, setShowTitle] = useState(true)
const [cooldown, setCooldown] = useState(time)
const [sfxBoump] = useSound(SFX_BOUMP_SOUND, {
volume: 0.2,
})
useEvent("game:startCooldown", () => {
sfxBoump()
setShowTitle(false)
})
useEvent("game:cooldown", (sec) => {
sfxBoump()
setCooldown(sec)
})
return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
{showTitle ? (
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{subject}
</h2>
) : (
<>
<div
className={clsx(
`anim-show bg-primary aspect-square h-32 transition-all md:h-60`,
)}
style={{
transform: `rotate(${45 * (time - cooldown)}deg)`,
}}
></div>
<span className="absolute text-6xl font-bold text-white drop-shadow-md md:text-8xl">
{cooldown}
</span>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,17 @@
import { PlayerStatusDataMap } from "@rahoot/common/types/game/status"
import Loader from "@rahoot/web/components/Loader"
type Props = {
data: PlayerStatusDataMap["WAIT"]
}
export default function Wait({ data: { text } }: Props) {
return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
<Loader />
<h2 className="mt-5 text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{text}
</h2>
</section>
)
}

View File

@@ -0,0 +1,30 @@
type Props = {
className?: string
fill?: string
}
export default function Circle({ className, fill = "#FFF" }: Props) {
return (
<svg
className={className}
viewBox="0 0 512 512"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Page-1"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<g id="icon" fill={fill} transform="translate(42.666667, 42.666667)">
<path
d="M213.333333,3.55271368e-14 C331.15408,3.55271368e-14 426.666667,95.5125867 426.666667,213.333333 C426.666667,331.15408 331.15408,426.666667 213.333333,426.666667 C95.5125867,426.666667 3.55271368e-14,331.15408 3.55271368e-14,213.333333 C3.55271368e-14,95.5125867 95.5125867,3.55271368e-14 213.333333,3.55271368e-14 Z"
id="Combined-Shape"
></path>
</g>
</g>
</svg>
)
}

View File

@@ -0,0 +1,41 @@
type Props = {
className?: string
}
export default function CricleCheck({ className }: Props) {
return (
<svg
fill="#22c55e"
width="800px"
height="800px"
viewBox="0 0 56.00 56.00"
xmlns="http://www.w3.org/2000/svg"
stroke="#22c55e"
strokeWidth="0.00056"
className={className}
>
<g strokeWidth="0" transform="translate(11.2,11.2), scale(0.6)">
<rect
x="0"
y="0"
width="56.00"
height="56.00"
rx="28"
fill="#ffffff"
strokeWidth="0"
/>
</g>
<g
strokeLinecap="round"
strokeLinejoin="round"
stroke="#CCCCCC"
strokeWidth="0.6719999999999999"
/>
<g>
<path d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0312 4.0937 27.9765 4.0937 C 14.8983 4.0937 4.0937 14.9453 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 24.7655 40.0234 C 23.9687 40.0234 23.3593 39.6719 22.6796 38.8750 L 15.9296 30.5312 C 15.5780 30.0859 15.3671 29.5234 15.3671 29.0078 C 15.3671 27.9063 16.2343 27.0625 17.2655 27.0625 C 17.9452 27.0625 18.5077 27.3203 19.0702 28.0469 L 24.6718 35.2890 L 35.5702 17.8281 C 36.0155 17.1016 36.6249 16.75 37.2343 16.75 C 38.2655 16.75 39.2733 17.4297 39.2733 18.5547 C 39.2733 19.0703 38.9687 19.6328 38.6640 20.1016 L 26.7577 38.8750 C 26.2421 39.6484 25.5858 40.0234 24.7655 40.0234 Z" />
</g>
</svg>
)
}

View File

@@ -0,0 +1,35 @@
type Props = {
className?: string
}
export default function CricleXmark({ className }: Props) {
return (
<svg
fill="#ef4444"
width="800px"
height="800px"
viewBox="0 0 56.00 56.00"
xmlns="http://www.w3.org/2000/svg"
stroke="#ef4444"
className={className}
>
<g strokeWidth="0" transform="translate(12.4,12.4), scale(0.6)">
<rect
x="0"
y="0"
width="56.00"
height="56.00"
rx="28"
fill="#ffffff"
strokeWidth="0"
/>
</g>
<g strokeLinecap="round" strokeLinejoin="round" />
<g>
<path d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0312 4.0937 27.9765 4.0937 C 14.8983 4.0937 4.0937 14.9453 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 19.5858 38.4063 C 18.4843 38.4063 17.5936 37.5156 17.5936 36.4141 C 17.5936 35.8750 17.8280 35.4063 18.2030 35.0547 L 25.1874 28.0234 L 18.2030 20.9922 C 17.8280 20.6641 17.5936 20.1719 17.5936 19.6328 C 17.5936 18.5547 18.4843 17.6875 19.5858 17.6875 C 20.1249 17.6875 20.5936 17.8984 20.9452 18.2734 L 27.9765 25.2812 L 35.0546 18.25 C 35.4530 17.8281 35.8749 17.6406 36.3905 17.6406 C 37.4921 17.6406 38.3827 18.5312 38.3827 19.6094 C 38.3827 20.1484 38.1952 20.5937 37.7968 20.9688 L 30.7655 28.0234 L 37.7733 35.0078 C 38.1249 35.3828 38.3593 35.8516 38.3593 36.4141 C 38.3593 37.5156 37.4687 38.4063 36.3671 38.4063 C 35.8046 38.4063 35.3358 38.1719 34.9843 37.8203 L 27.9765 30.7890 L 20.9921 37.8203 C 20.6405 38.1953 20.1249 38.4063 19.5858 38.4063 Z" />
</g>
</svg>
)
}

View File

@@ -0,0 +1,46 @@
type Props = {
className?: string
fill?: string
stroke?: string
}
export default function Pentagon({ className, fill, stroke }: Props) {
return (
<svg
className={className}
fill={fill}
height="800px"
width="800px"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
viewBox="-40.96 -40.96 593.93 593.93"
transform="rotate(180)"
stroke={fill}
stroke-width="0.005120100000000001"
>
<g stroke-width="0" />
<g
strokeLinecap="round"
strokeLinejoin="round"
stroke={stroke}
strokeWidth="71.6814"
>
<g>
<g>
<path d="M507.804,200.28L262.471,12.866c-3.84-2.923-9.131-2.923-12.949,0L4.188,200.28c-3.605,2.773-5.077,7.531-3.648,11.84 l93.717,281.92c1.451,4.373,5.525,7.296,10.133,7.296h303.253c4.587,0,8.683-2.944,10.133-7.296l93.717-281.92 C512.882,207.789,511.41,203.053,507.804,200.28z" />{" "}
</g>
</g>
</g>
<g>
<g>
<g>
<path d="M507.804,200.28L262.471,12.866c-3.84-2.923-9.131-2.923-12.949,0L4.188,200.28c-3.605,2.773-5.077,7.531-3.648,11.84 l93.717,281.92c1.451,4.373,5.525,7.296,10.133,7.296h303.253c4.587,0,8.683-2.944,10.133-7.296l93.717-281.92 C512.882,207.789,511.41,203.053,507.804,200.28z" />{" "}
</g>
</g>
</g>
</svg>
)
}

View File

@@ -0,0 +1,24 @@
type Props = {
className?: string
fill?: string
}
export default function Rhombus({ className, fill = "#FFF" }: Props) {
return (
<svg
className={className}
fill={fill}
viewBox="-56.32 -56.32 624.64 624.64"
xmlns="http://www.w3.org/2000/svg"
transform="rotate(45)"
>
<g strokeWidth="0" />
<g strokeLinecap="round" strokeLinejoin="round" />
<g>
<rect x="48" y="48" width="416" height="416" />
</g>
</svg>
)
}

View File

@@ -0,0 +1,17 @@
type Props = {
className?: string
fill?: string
}
export default function Square({ className, fill = "#FFF" }: Props) {
return (
<svg
className={className}
fill={fill}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="48" y="48" width="416" height="416" />
</svg>
)
}

View File

@@ -0,0 +1,17 @@
type Props = {
className?: string
fill?: string
}
export default function Triangle({ className, fill = "#FFF" }: Props) {
return (
<svg
className={className}
fill={fill}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<polygon points="256 32 20 464 492 464 256 32" />
</svg>
)
}

View File

@@ -0,0 +1,140 @@
/* eslint-disable no-empty-function */
"use client"
import {
ClientToServerEvents,
ServerToClientEvents,
} from "@rahoot/common/types/game/socket"
import ky from "ky"
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react"
import { io, Socket } from "socket.io-client"
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>
interface SocketContextValue {
socket: TypedSocket | null
isConnected: boolean
connect: () => void
disconnect: () => void
reconnect: () => void
}
const SocketContext = createContext<SocketContextValue>({
socket: null,
isConnected: false,
connect: () => {},
disconnect: () => {},
reconnect: () => {},
})
const getSocketServer = async () => {
const res = await ky.get("/socket").json<{ url: string }>()
return res.url
}
export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
const [socket, setSocket] = useState<TypedSocket | null>(null)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
let s: TypedSocket | null = null
const initSocket = async () => {
try {
const socketUrl = await getSocketServer()
s = io(socketUrl, {
transports: ["websocket"],
autoConnect: false,
})
setSocket(s)
s.on("connect", () => {
setIsConnected(true)
})
s.on("disconnect", () => {
console.log("Socket disconnected")
setIsConnected(false)
})
s.on("connect_error", (err) => {
console.error("Connection error:", err.message)
})
} catch (error) {
console.error("Failed to initialize socket:", error)
}
}
initSocket()
return () => {
s?.disconnect()
}
}, [])
const connect = useCallback(() => {
if (socket && !socket.connected) {
console.log("🔌 Manual connect")
socket.connect()
}
}, [socket])
const disconnect = useCallback(() => {
if (socket && socket.connected) {
console.log("🧹 Manual disconnect")
socket.disconnect()
}
}, [socket])
const reconnect = useCallback(() => {
if (socket) {
console.log("♻️ Manual reconnect")
socket.disconnect()
socket.connect()
}
}, [socket])
return (
<SocketContext.Provider
value={{
socket,
isConnected,
connect,
disconnect,
reconnect,
}}
>
{children}
</SocketContext.Provider>
)
}
export const useSocket = () => useContext(SocketContext)
export const useEvent = <E extends keyof ServerToClientEvents>(
event: E,
callback: ServerToClientEvents[E],
) => {
const { socket } = useSocket()
useEffect(() => {
if (!socket) {
return
}
socket.on(event, callback as any)
// eslint-disable-next-line consistent-return
return () => {
socket.off(event, callback as any)
}
}, [socket, event, callback])
}

14
packages/web/src/env.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
const env = createEnv({
server: {
SOCKET_URL: z.string().default("http://localhost:3001"),
},
runtimeEnv: {
SOCKET_URL: process.env.SOCKET_URL,
},
})
export default env

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from "react"
const useScreenSize = () => {
const [screenSize, setScreenSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
})
useEffect(() => {
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener("resize", handleResize)
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
return screenSize
}
export default useScreenSize

View File

@@ -0,0 +1,44 @@
import { StatusDataMap } from "@rahoot/common/types/game/status"
import { create } from "zustand"
export type Status<T> = {
[K in keyof T]: { name: K; data: T[K] }
}[keyof T]
export function createStatus<T, K extends keyof T>(
name: K,
data: T[K],
): Status<T> {
return { name, data }
}
type GameStore<T> = {
status: Status<T>
// eslint-disable-next-line no-unused-vars
setStatus: <K extends keyof T>(name: K, data: T[K]) => void
resetStatus: () => void
}
export const usePlayerGameStore = create<GameStore<StatusDataMap>>((set) => {
const initialStatus = createStatus<StatusDataMap, "WAIT">("WAIT", {
text: "Waiting for the players",
})
return {
status: initialStatus,
setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: initialStatus }),
}
})
export const useManagerGameStore = create<GameStore<StatusDataMap>>((set) => {
const initialStatus = createStatus<StatusDataMap, "SHOW_ROOM">("SHOW_ROOM", {
text: "Waiting for the players",
})
return {
status: initialStatus,
setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: initialStatus }),
}
})

View File

@@ -0,0 +1,37 @@
/* eslint-disable no-unused-vars */
import { create } from "zustand"
type PlayerState = {
gameId?: string
username?: string
points?: number
}
type PlayerStore = {
player: PlayerState | null
login: (gameId: string) => void
join: (username: string) => void
updatePoints: (points: number) => void
logout: () => void
}
export const usePlayerStore = create<PlayerStore>((set) => ({
player: null,
login: (username) =>
set((state) => ({
player: { ...state.player, username },
})),
join: (gameId) =>
set((state) => ({
player: { ...state.player, gameId, points: 0 },
})),
updatePoints: (points) =>
set((state) => ({
player: { ...state.player, points },
})),
logout: () => set({ player: null }),
}))

View File

@@ -0,0 +1,63 @@
import Answers from "@rahoot/web/components/game/states/Answers"
import Leaderboard from "@rahoot/web/components/game/states/Leaderboard"
import Podium from "@rahoot/web/components/game/states/Podium"
import Prepared from "@rahoot/web/components/game/states/Prepared"
import Question from "@rahoot/web/components/game/states/Question"
import Responses from "@rahoot/web/components/game/states/Responses"
import Result from "@rahoot/web/components/game/states/Result"
import Room from "@rahoot/web/components/game/states/Room"
import Start from "@rahoot/web/components/game/states/Start"
import Wait from "@rahoot/web/components/game/states/Wait"
import { Status } from "@rahoot/common/types/game/status"
import Circle from "@rahoot/web/components/icons/Circle"
import Rhombus from "@rahoot/web/components/icons/Rhombus"
import Square from "@rahoot/web/components/icons/Square"
import Triangle from "@rahoot/web/components/icons/Triangle"
export const ANSWERS_COLORS = [
"bg-red-500",
"bg-blue-500",
"bg-yellow-500",
"bg-green-500",
]
export const ANSWERS_ICONS = [Triangle, Rhombus, Circle, Square]
export const GAME_STATES = {
status: {
name: Status.WAIT,
data: { text: "Waiting for the players" },
},
question: {
current: 1,
total: null,
},
}
export const GAME_STATE_COMPONENTS = {
[Status.SELECT_ANSWER]: Answers,
[Status.SHOW_QUESTION]: Question,
[Status.WAIT]: Wait,
[Status.SHOW_START]: Start,
[Status.SHOW_RESULT]: Result,
[Status.SHOW_PREPARED]: Prepared,
}
export const GAME_STATE_COMPONENTS_MANAGER = {
...GAME_STATE_COMPONENTS,
[Status.SHOW_ROOM]: Room,
[Status.SHOW_RESPONSES]: Responses,
[Status.SHOW_LEADERBOARD]: Leaderboard,
[Status.FINISHED]: Podium,
}
export const SFX_ANSWERS_MUSIC = "/sounds/answersMusic.mp3"
export const SFX_ANSWERS_SOUND = "/sounds/answersSound.mp3"
export const SFX_RESULTS_SOUND = "/sounds/results.mp3"
export const SFX_SHOW_SOUND = "/sounds/show.mp3"
export const SFX_BOUMP_SOUND = "/sounds/boump.mp3"
export const SFX_PODIUM_THREE = "/sounds/three.mp3"
export const SFX_PODIUM_SECOND = "/sounds/second.mp3"
export const SFX_PODIUM_FIRST = "/sounds/first.mp3"
export const SFX_SNEAR_ROOL = "/sounds/snearRoll.mp3"

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts"
],
"exclude": ["node_modules"]
}