diff --git a/cypress/e2e/3-guess-the-guild/0-guess-name.spec.ts b/cypress/e2e/3-guess-the-guild/0-guess-name.spec.ts
new file mode 100644
index 000000000..a184e2e30
--- /dev/null
+++ b/cypress/e2e/3-guess-the-guild/0-guess-name.spec.ts
@@ -0,0 +1,154 @@
+const setGameModeToGuessName = () => {
+ Cypress.on("window:before:load", (win) => {
+ Object.defineProperty(win.Math, "random", {
+ value: function () {
+ return 0.1
+ },
+ writable: false,
+ })
+ })
+}
+
+describe.skip("api fetching returns an error", () => {
+ it("displays error toast message", () => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 500,
+ }).as("failedGuildLoad")
+
+ cy.visit("/guess-the-guild")
+
+ cy.wait("@failedGuildLoad")
+
+ cy.get(".chakra-toast", { timeout: 5000 }).should("be.visible")
+ })
+})
+
+describe.skip("in 'guess name' mode", () => {
+ beforeEach(() => {
+ cy.visit("/guess-the-guild")
+ setGameModeToGuessName()
+ cy.contains("Let's Go!").click()
+ })
+
+ it("is not possible to press submit, until an answer is selected", () => {
+ cy.getByDataTest("submit").should("be.disabled")
+ })
+
+ it("is possible to press submit, after an answer is selected", () => {
+ cy.getByDataTest("answer-button").first().click()
+ cy.getByDataTest("submit").should("not.be.disabled")
+ })
+
+ it("score indicator shows up", () => {
+ cy.get("#score-indicator").should("be.visible")
+ })
+})
+
+describe.skip("game mode answer submit", () => {
+ it("shows success message for good answer", () => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 200,
+ fixture: "testGuilds.json",
+ }).as("guildsLoaded")
+
+ cy.visit("/guess-the-guild")
+ setGameModeToGuessName()
+
+ cy.contains("Let's Go!").click()
+ cy.getByDataTest("answer-button").first().click()
+ cy.getByDataTest("submit").click()
+ cy.get('[data-status="success"]').should("be.visible")
+ })
+
+ it("shows wrong answer message", () => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 200,
+ fixture: "testGuilds.json",
+ }).as("guildsLoaded")
+
+ cy.visit("/guess-the-guild")
+ setGameModeToGuessName()
+
+ cy.contains("Let's Go!").click()
+ cy.getByDataTest("answer-button").last().click()
+ cy.getByDataTest("submit").click()
+ cy.get('[data-status="warning"]').should("be.visible")
+ })
+
+ it("increases score by 1 on good answer", () => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 200,
+ fixture: "testGuilds.json",
+ }).as("guildsLoaded")
+
+ cy.visit("/guess-the-guild")
+ setGameModeToGuessName()
+
+ cy.contains("Let's Go!").click()
+
+ cy.get("span").contains("Score:").parent().contains("0").should("be.visible")
+
+ cy.getByDataTest("answer-button").first().click()
+ cy.getByDataTest("submit").click()
+
+ cy.contains("1 points").should("be.visible")
+ })
+
+ it("does not change score on wrong answer", () => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 200,
+ fixture: "testGuilds.json",
+ }).as("guildsLoaded")
+
+ cy.visit("/guess-the-guild")
+ setGameModeToGuessName()
+
+ cy.contains("Let's Go!").click()
+
+ cy.get("span").contains("Score:").parent().contains("0").should("be.visible")
+
+ cy.getByDataTest("answer-button").last().click()
+ cy.getByDataTest("submit").click()
+
+ cy.get("span").contains("Score:").parent().contains("0").should("be.visible")
+ })
+})
+
+describe("game screen order", () => {
+ beforeEach(() => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 200,
+ fixture: "testGuilds.json",
+ }).as("guildsLoaded")
+
+ cy.visit("/guess-the-guild")
+ setGameModeToGuessName()
+
+ cy.contains("Let's Go!").click()
+ })
+
+ it.skip("game continues on good answer", () => {
+ cy.getByDataTest("answer-button").first().click()
+ cy.getByDataTest("submit").click()
+
+ cy.getByDataTest("continue").should("be.visible")
+ cy.getByDataTest("continue").click()
+ cy.getByDataTest("submit").should("be.visible")
+ })
+
+ it.skip("game ends after wrong answer", () => {
+ cy.getByDataTest("answer-button").last().click()
+ cy.getByDataTest("submit").click()
+ cy.getByDataTest("end-game").should("be.visible")
+ cy.getByDataTest("end-game").click()
+ cy.contains("Game Over").should("be.visible")
+ })
+
+ it("game restarts after end game screen", () => {
+ cy.getByDataTest("answer-button").last().click()
+ cy.getByDataTest("submit").click()
+ cy.getByDataTest("end-game").click()
+ cy.contains("Try Again!").click()
+ cy.contains("GuildGesser").should("be.visible")
+ })
+})
diff --git a/cypress/e2e/3-guess-the-guild/1-assign-logos.spec.ts b/cypress/e2e/3-guess-the-guild/1-assign-logos.spec.ts
new file mode 100644
index 000000000..a3ce2b5c0
--- /dev/null
+++ b/cypress/e2e/3-guess-the-guild/1-assign-logos.spec.ts
@@ -0,0 +1,31 @@
+const setGameModeToAssignLogos = () => {
+ Cypress.on("window:before:load", (win) => {
+ Object.defineProperty(win.Math, "random", {
+ value: function () {
+ return 0.9
+ },
+ writable: false,
+ })
+ })
+}
+
+describe("assign logos game mode", () => {
+ beforeEach(() => {
+ cy.intercept(`${Cypress.env("guildApiUrl")}/guilds?*`, {
+ statusCode: 200,
+ fixture: "testGuilds.json",
+ }).as("guildsLoaded")
+
+ cy.visit("/guess-the-guild")
+ setGameModeToAssignLogos()
+ cy.contains("Let's Go!").click()
+ })
+
+ it("not possible to press submit when not all logos are assigned", () => {
+ cy.getByDataTest("submit").should("be.disabled")
+ })
+
+ it("score indicator shows up", () => {
+ cy.get("#score-indicator").should("be.visible")
+ })
+})
diff --git a/cypress/fixtures/testGuilds.json b/cypress/fixtures/testGuilds.json
new file mode 100644
index 000000000..00169c957
--- /dev/null
+++ b/cypress/fixtures/testGuilds.json
@@ -0,0 +1,46 @@
+[
+ {
+ "id": 1,
+ "name": "Guild 1",
+ "urlName": "guild1",
+ "imageUrl": "",
+ "roles": ["Role1"],
+ "platforms": ["DISCORD"],
+ "memberCount": 1234,
+ "rolesCount": 5678,
+ "tags": ["VERIFIED"]
+ },
+ {
+ "id": 2,
+ "name": "Guild 2",
+ "urlName": "guild2",
+ "imageUrl": "",
+ "roles": ["Role1"],
+ "platforms": ["DISCORD"],
+ "memberCount": 1234,
+ "rolesCount": 5678,
+ "tags": ["VERIFIED"]
+ },
+ {
+ "id": 3,
+ "name": "Guild 3",
+ "urlName": "guild3",
+ "imageUrl": "",
+ "roles": ["Role1"],
+ "platforms": ["DISCORD"],
+ "memberCount": 1234,
+ "rolesCount": 5678,
+ "tags": ["VERIFIED"]
+ },
+ {
+ "id": 4,
+ "name": "Guild 4",
+ "urlName": "guild4",
+ "imageUrl": "",
+ "roles": ["Role1"],
+ "platforms": ["DISCORD"],
+ "memberCount": 1234,
+ "rolesCount": 5678,
+ "tags": ["VERIFIED"]
+ }
+]
diff --git a/package-lock.json b/package-lock.json
index d35742bbc..380412bc2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@chakra-ui/anatomy": "^2.1.2",
"@chakra-ui/react": "^2.6.1",
"@chakra-ui/theme-tools": "^2.0.17",
+ "@dnd-kit/core": "^6.1.0",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@hcaptcha/react-hcaptcha": "^1.4.4",
@@ -3235,6 +3236,42 @@
"ms": "^2.1.1"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
+ "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz",
+ "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.11.0",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
diff --git a/package.json b/package.json
index 094b12834..df7a887cb 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@chakra-ui/anatomy": "^2.1.2",
"@chakra-ui/react": "^2.6.1",
"@chakra-ui/theme-tools": "^2.0.17",
+ "@dnd-kit/core": "^6.1.0",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@hcaptcha/react-hcaptcha": "^1.4.4",
diff --git a/src/components/guess-the-guild/EndGame.tsx b/src/components/guess-the-guild/EndGame.tsx
new file mode 100644
index 000000000..8b50f0c59
--- /dev/null
+++ b/src/components/guess-the-guild/EndGame.tsx
@@ -0,0 +1,81 @@
+import { Alert, Button, Divider, Heading, Text, VStack } from "@chakra-ui/react"
+import pluralize from "utils/pluralize"
+
+const EndGame = ({
+ score,
+ highscore,
+ onRestart,
+}: {
+ score: number
+ highscore: number
+ onRestart: () => void
+}) => {
+ const getMessage = () => {
+ if (score <= 10) return "Better luck next time!"
+ if (score <= 20) return "You're getting the hang of it!"
+ return "It looks like we're dealing with a professional 👀"
+ }
+
+ return (
+ <>
+
+
+ Game Over
+
+ {getMessage()}
+
+
+
+ Final Score
+
+
+ {score} points
+
+
+ {score > highscore && (
+ <>
+
+ 🎉 New Highscore!
+
+ >
+ )}
+
+ {score <= highscore && (
+ <>
+
+ Only {pluralize(highscore - score + 1, "point")} to go
+ to beat your current highscore!
+
+ >
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+export default EndGame
diff --git a/src/components/guess-the-guild/StartGame.tsx b/src/components/guess-the-guild/StartGame.tsx
new file mode 100644
index 000000000..5c03fe41a
--- /dev/null
+++ b/src/components/guess-the-guild/StartGame.tsx
@@ -0,0 +1,111 @@
+import { Divider, Heading, Text, VStack } from "@chakra-ui/react"
+import Button from "components/common/Button"
+import { DIFFICULTY_GUILD_POOL_SIZE } from "pages/guess-the-guild"
+import { Difficulty } from "./guess-the-guild-types"
+import { DIFFICULTY_MULTIPLIERS } from "./hooks/useGameLogic"
+
+type Props = {
+ onStart: () => void
+ difficulty: Difficulty
+ onDifficultyChange: (d: Difficulty) => void
+ highscore: number
+}
+
+const StartGame = ({
+ onStart,
+ difficulty,
+ onDifficultyChange,
+ highscore,
+}: Props) => (
+
+
+ GuildGesser 1.0
+
+
+ Are you an expert on Guilds?
Test your knowledge in our guild guesser
+ mini game!
+
+
+
+
+
+
+ {"Your highscore"}
+
+
+ {highscore}
+
+
+
+
+
+
+ {"Select Difficulty"}
+
+
+
+
+
+
+
+
+ Selection from the top {DIFFICULTY_GUILD_POOL_SIZE[difficulty]} guilds.
+
+ Solving this difficulty awards {DIFFICULTY_MULTIPLIERS[difficulty]}x the
+ usual points.
+
+
+
+
+)
+
+export default StartGame
diff --git a/src/components/guess-the-guild/assign-logos/AssignLogos.tsx b/src/components/guess-the-guild/assign-logos/AssignLogos.tsx
new file mode 100644
index 000000000..f2197a6af
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/AssignLogos.tsx
@@ -0,0 +1,154 @@
+import { Divider, Heading, VStack } from "@chakra-ui/react"
+import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core"
+import Button from "components/common/Button"
+import GuildLogo from "components/common/GuildLogo"
+import React, { useMemo, useState } from "react"
+import { GuildBase } from "types"
+import ResultAlert from "../components/ResultAlert"
+import { GameModeProps } from "../guess-the-guild-types"
+import Draggable from "./components/Draggable"
+import GuildCardWithDropzone from "./components/GuildCardWithDropzone"
+import SourceDropzone from "./components/SourceDropzone"
+import useDragAndDrop, { START_ZONE_ID } from "./hooks/useDragAndDrop"
+
+const AssignLogos = ({ guilds, onNext, onExit, onCorrect }: GameModeProps) => {
+ const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false)
+ const avatarSize = { base: "60px", sm: "70px", md: "80px" }
+
+ const { startZone, dropzones, movingGuild, dragStart, dragEnd } =
+ useDragAndDrop(guilds)
+
+ const handleDragStart = (event: DragStartEvent) => {
+ if (isAnswerSubmitted) return
+ dragStart(event)
+ }
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ if (isAnswerSubmitted) return
+ dragEnd(event)
+ }
+
+ const renderDraggableAvatar = (guild: GuildBase) => {
+ if (!guild) return
+ return (
+
+ {guild.id != movingGuild?.id && (
+
+
+
+ )}
+
+ )
+ }
+
+ const isAnswerCorrect = Object.entries(dropzones).every(
+ ([zoneId, guild]) => `${guild?.id}` === zoneId
+ )
+ const isLogoCorrectForGuild = (guild: GuildBase) =>
+ dropzones[guild.id]?.id === guild?.id
+
+ const canSubmit = useMemo(() => {
+ for (const key of Object.keys(dropzones)) {
+ if (key !== "source" && dropzones[key] === null) {
+ return false
+ }
+ }
+ return true
+ }, [dropzones])
+
+ const handleSubmit = () => {
+ setIsAnswerSubmitted(true)
+ if (isAnswerCorrect) onCorrect()
+ }
+
+ const liftUpAnimation = `
+ @keyframes liftUp {
+ from {
+ transform: scale(1.0);
+ }
+ to {
+ transform: scale(1.1);
+ }
+ }`
+
+ return (
+ <>
+
+
+
+ Guess the guild by the logo!
+
+
+
+ {!isAnswerSubmitted && (
+ <>
+
+ {startZone.map((guild) => renderDraggableAvatar(guild))}
+
+ >
+ )}
+
+
+ {movingGuild && !isAnswerSubmitted ? (
+
+ ) : null}
+
+
+ {guilds.map((guild) => (
+
+ {renderDraggableAvatar(dropzones[`${guild.id}`])}
+
+ ))}
+
+
+
+
+ {isAnswerSubmitted && }
+
+ {!isAnswerSubmitted && (
+
+ )}
+
+ {isAnswerSubmitted && isAnswerCorrect && (
+
+ )}
+
+ {isAnswerSubmitted && !isAnswerCorrect && (
+
+ )}
+
+ >
+ )
+}
+
+export default AssignLogos
diff --git a/src/components/guess-the-guild/assign-logos/components/Draggable.tsx b/src/components/guess-the-guild/assign-logos/components/Draggable.tsx
new file mode 100644
index 000000000..8a79e4761
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/components/Draggable.tsx
@@ -0,0 +1,19 @@
+import { useDraggable } from "@dnd-kit/core"
+import { ReactNode } from "react"
+
+const Draggable = ({ id, children }: { id: string; children?: ReactNode }) => {
+ const { attributes, listeners, setNodeRef } = useDraggable({ id })
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default Draggable
diff --git a/src/components/guess-the-guild/assign-logos/components/GuildCardWithDropzone.tsx b/src/components/guess-the-guild/assign-logos/components/GuildCardWithDropzone.tsx
new file mode 100644
index 000000000..576d92728
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/components/GuildCardWithDropzone.tsx
@@ -0,0 +1,98 @@
+import {
+ HStack,
+ Icon,
+ Tag,
+ TagLabel,
+ TagLeftIcon,
+ Text,
+ VStack,
+ Wrap,
+ useColorModeValue,
+} from "@chakra-ui/react"
+import Card from "components/common/Card"
+import GuildLogo from "components/common/GuildLogo"
+import { ArrowDown, Users } from "phosphor-react"
+import { ReactNode } from "react"
+import { GuildBase } from "types"
+import pluralize from "utils/pluralize"
+import TargetDropzone from "./TargetDropzone"
+
+const GuildCardWithDropzone = ({
+ guild,
+ avatarSize,
+ children,
+ isAnswerSubmitted,
+ isLogoCorrect,
+}: {
+ guild: GuildBase
+ avatarSize: any
+ children: ReactNode
+ isAnswerSubmitted: boolean
+ isLogoCorrect: boolean
+}) => {
+ const bgColor = useColorModeValue("gray.100", "whiteAlpha.200")
+
+ return (
+ <>
+
+
+
+ {children}
+
+
+
+ {guild.name}
+
+
+
+ {pluralize(guild.rolesCount, "role")}
+
+
+
+
+ {new Intl.NumberFormat("en", { notation: "compact" }).format(
+ guild.memberCount
+ )}
+
+
+
+
+
+
+ {isAnswerSubmitted && !isLogoCorrect && (
+ <>
+
+
+
+
+ Correct Answer
+
+
+ >
+ )}
+ >
+ )
+}
+
+export default GuildCardWithDropzone
diff --git a/src/components/guess-the-guild/assign-logos/components/SkeletonAssignLogos.tsx b/src/components/guess-the-guild/assign-logos/components/SkeletonAssignLogos.tsx
new file mode 100644
index 000000000..5576ecae2
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/components/SkeletonAssignLogos.tsx
@@ -0,0 +1,50 @@
+import {
+ Card,
+ HStack,
+ SkeletonCircle,
+ SkeletonText,
+ VStack,
+ useColorModeValue,
+} from "@chakra-ui/react"
+
+const SkeletonAssignLogos = () => {
+ const bgColor = useColorModeValue("gray.100", "whiteAlpha.200")
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ .{" "}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SkeletonAssignLogos
diff --git a/src/components/guess-the-guild/assign-logos/components/SourceDropzone.tsx b/src/components/guess-the-guild/assign-logos/components/SourceDropzone.tsx
new file mode 100644
index 000000000..0b58106fe
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/components/SourceDropzone.tsx
@@ -0,0 +1,55 @@
+import { Box, useColorModeValue, useTheme } from "@chakra-ui/react"
+import { useDroppable } from "@dnd-kit/core"
+import { ReactNode } from "react"
+
+const SourceDropzone = ({
+ id,
+ children,
+ size,
+}: {
+ id: string
+ children?: ReactNode
+ size: any
+}) => {
+ const { isOver, setNodeRef } = useDroppable({ id })
+ const borderColor = "transparent"
+ const borderColorHover = useColorModeValue("green.400", "green.600")
+
+ const theme = useTheme()
+ const paddingValue = theme.space[2]
+
+ const addPaddingToSize = (sizeValue) => `calc(${sizeValue} + 3*${paddingValue}) `
+
+ const minHeightWithPadding = () => {
+ const pSize = size
+
+ return {
+ base: addPaddingToSize(pSize.base),
+ sm: addPaddingToSize(pSize.sm),
+ md: addPaddingToSize(pSize.md),
+ }
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default SourceDropzone
diff --git a/src/components/guess-the-guild/assign-logos/components/TargetDropzone.tsx b/src/components/guess-the-guild/assign-logos/components/TargetDropzone.tsx
new file mode 100644
index 000000000..02f11f7aa
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/components/TargetDropzone.tsx
@@ -0,0 +1,39 @@
+import { Box, useColorModeValue } from "@chakra-ui/react"
+import { useDroppable } from "@dnd-kit/core"
+import { ReactNode } from "react"
+
+const TargetDropzone = ({
+ id,
+ children,
+ avatarSize,
+}: {
+ id: string
+ children?: ReactNode
+ avatarSize: any
+}) => {
+ const { isOver, setNodeRef } = useDroppable({ id })
+ const borderColor = useColorModeValue("gray.300", "gray.500")
+ const borderColorHover = useColorModeValue("green.400", "green.600")
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default TargetDropzone
diff --git a/src/components/guess-the-guild/assign-logos/hooks/useDragAndDrop.tsx b/src/components/guess-the-guild/assign-logos/hooks/useDragAndDrop.tsx
new file mode 100644
index 000000000..26f93b482
--- /dev/null
+++ b/src/components/guess-the-guild/assign-logos/hooks/useDragAndDrop.tsx
@@ -0,0 +1,105 @@
+import { DragEndEvent, DragStartEvent } from "@dnd-kit/core"
+import { useState } from "react"
+import { GuildBase } from "types"
+import { shuffleArray } from "utils/shuffleArray"
+
+export const START_ZONE_ID = "source"
+type DropzoneDict = Record
+
+const useDragAndDrop = (guilds: GuildBase[]) => {
+ const [startZone, setStartZone] = useState(shuffleArray(guilds))
+ const [movingGuild, setMovingGuild] = useState(null)
+
+ const initialDropzones = Object.fromEntries(
+ guilds.map((guild) => [guild.id, null])
+ )
+
+ const [dropzones, setDropzones] = useState(initialDropzones)
+
+ const addToStartZone = (guild: GuildBase) => {
+ setStartZone((prev) => [...prev, guild])
+ }
+
+ const removeFromStartZone = (guildId: number) => {
+ setStartZone((prev) => prev.filter((g) => g.id !== guildId))
+ }
+
+ const dragStart = (event: DragStartEvent) => {
+ setMovingGuild(guilds.find((g) => g.id.toString() === event.active.id))
+ }
+
+ const dragEnd = (event: DragEndEvent) => {
+ const { over } = event
+
+ if (!over || !movingGuild) {
+ setMovingGuild(null)
+ return
+ }
+
+ const sourceZoneId = findSourceZoneId(movingGuild)
+ const targetZoneId = over.id as string
+
+ if (!isMoveAllowed(sourceZoneId, targetZoneId)) {
+ setMovingGuild(null)
+ return
+ }
+
+ updateDropzones(sourceZoneId, targetZoneId)
+ updateStartZone(sourceZoneId, targetZoneId)
+
+ setMovingGuild(null)
+ }
+
+ const updateDropzones = (sourceZoneId: string, targetZoneId: string) => {
+ const updatedDropzones = { ...dropzones }
+
+ if (targetZoneId !== START_ZONE_ID) {
+ updatedDropzones[targetZoneId] = guilds.find(
+ (guild) => guild.id === movingGuild.id
+ )
+ }
+
+ if (sourceZoneId !== START_ZONE_ID) {
+ updatedDropzones[sourceZoneId] = null
+ }
+
+ setDropzones(updatedDropzones)
+ }
+
+ const updateStartZone = (sourceZoneId: string, targetZoneId: string) => {
+ if (targetZoneId === START_ZONE_ID) {
+ addToStartZone(movingGuild)
+ }
+ if (sourceZoneId === START_ZONE_ID) {
+ removeFromStartZone(movingGuild.id)
+ }
+ }
+
+ const isMoveAllowed = (sourceZoneId: string, targetZoneId: string): boolean => {
+ if (sourceZoneId === targetZoneId) return false
+
+ if (targetZoneId !== START_ZONE_ID && dropzones[targetZoneId]) return false
+
+ return true
+ }
+
+ const findSourceZoneId = (guild: GuildBase): string | null => {
+ const isInStartZone = startZone.some((g) => g.id === guild.id)
+ if (isInStartZone) return START_ZONE_ID
+
+ const sourceDropzone = Object.entries(dropzones).find(
+ ([_, dzGuild]) => dzGuild?.id === guild.id
+ )
+ return sourceDropzone ? sourceDropzone[0] : null
+ }
+
+ return {
+ startZone,
+ dropzones,
+ movingGuild,
+ dragStart,
+ dragEnd,
+ }
+}
+
+export default useDragAndDrop
diff --git a/src/components/guess-the-guild/components/ResultAlert.tsx b/src/components/guess-the-guild/components/ResultAlert.tsx
new file mode 100644
index 000000000..aef5980b3
--- /dev/null
+++ b/src/components/guess-the-guild/components/ResultAlert.tsx
@@ -0,0 +1,15 @@
+import { Alert, AlertIcon } from "@chakra-ui/react"
+
+const ResultAlert = ({ isAnswerCorrect }: { isAnswerCorrect: boolean }) => (
+ <>
+
+ {isAnswerCorrect ? "Your answer is correct!" : "Wrong answer!"}
+
+ >
+)
+
+export default ResultAlert
diff --git a/src/components/guess-the-guild/components/ScoreIndicator.tsx b/src/components/guess-the-guild/components/ScoreIndicator.tsx
new file mode 100644
index 000000000..9df5918f3
--- /dev/null
+++ b/src/components/guess-the-guild/components/ScoreIndicator.tsx
@@ -0,0 +1,56 @@
+import { Alert, HStack, Progress, Tag, chakra } from "@chakra-ui/react"
+import Card from "components/common/Card"
+
+const ScoreIndicator = ({
+ score,
+ highscore,
+}: {
+ score: number
+ highscore: number
+}) => {
+ const isHighscore = score > highscore
+
+ return (
+
+ {!isHighscore && (
+ <>
+
+
+
+ Score:{" "}
+ {" "}
+ {score}
+
+
+
+ Highscore:{" "}
+ {" "}
+ {highscore}
+
+
+
+
+ >
+ )}
+
+ {isHighscore && (
+ <>
+
+ 🎉 New Highscore!{" "}
+
+ {score} points
+
+
+ >
+ )}
+
+ )
+}
+
+export default ScoreIndicator
diff --git a/src/components/guess-the-guild/guess-name/GuessName.tsx b/src/components/guess-the-guild/guess-name/GuessName.tsx
new file mode 100644
index 000000000..0fe5f537b
--- /dev/null
+++ b/src/components/guess-the-guild/guess-name/GuessName.tsx
@@ -0,0 +1,85 @@
+import { Button, Divider, Heading, Text, VStack } from "@chakra-ui/react"
+import GuildLogo from "components/common/GuildLogo"
+import { useState } from "react"
+import { GuildBase } from "types"
+import ResultAlert from "../components/ResultAlert"
+import { GameModeProps } from "../guess-the-guild-types"
+import AnswerButton from "./components/AnswerButton"
+
+const getRandomGuild = (guilds: GuildBase[]) =>
+ guilds[Math.floor(Math.random() * guilds.length)]
+
+const GuessName = ({ guilds, onNext, onExit, onCorrect }: GameModeProps) => {
+ const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false)
+ const [solutionGuild] = useState(getRandomGuild(guilds))
+ const [selectedGuildId, setSelectedGuildId] = useState()
+
+ const isAnswerCorrect = selectedGuildId === solutionGuild.id
+
+ const handleSubmit = () => {
+ setIsAnswerSubmitted(true)
+ if (isAnswerCorrect) onCorrect()
+ }
+
+ return (
+ <>
+
+
+ Guess the guild by the logo!
+
+
+
+ {isAnswerSubmitted ? solutionGuild.name : "???"}
+
+
+
+ {guilds.map((guild) => (
+ setSelectedGuildId(guild.id)}
+ />
+ ))}
+
+
+
+
+ {isAnswerSubmitted && }
+
+ {!isAnswerSubmitted && (
+
+ )}
+
+ {isAnswerSubmitted && isAnswerCorrect && (
+
+ )}
+
+ {isAnswerSubmitted && !isAnswerCorrect && (
+
+ )}
+
+ >
+ )
+}
+
+export default GuessName
diff --git a/src/components/guess-the-guild/guess-name/components/AnswerButton.tsx b/src/components/guess-the-guild/guess-name/components/AnswerButton.tsx
new file mode 100644
index 000000000..a4718b156
--- /dev/null
+++ b/src/components/guess-the-guild/guess-name/components/AnswerButton.tsx
@@ -0,0 +1,60 @@
+import Button from "components/common/Button"
+import { GuildBase } from "types"
+
+type Props = {
+ guild: GuildBase
+ selectedGuildId?: number
+ solutionGuild: GuildBase
+ isAnswerSubmitted: boolean
+ onSelect: (guildId: number) => void
+}
+
+const AnswerButton = ({
+ guild,
+ selectedGuildId,
+ solutionGuild,
+ isAnswerSubmitted,
+ onSelect,
+}: Props) => {
+ let colorScheme = "gray"
+ let isDisabled = false
+ let variant = "solid"
+ let isActive = false
+
+ if (isAnswerSubmitted) {
+ if (guild.id === selectedGuildId) {
+ colorScheme = solutionGuild.id === selectedGuildId ? "green" : "red"
+ } else if (guild.id === solutionGuild.id) {
+ colorScheme = "green"
+ variant = "outline"
+ } else {
+ isDisabled = true
+ colorScheme = "gray"
+ variant = "solid"
+ }
+ } else {
+ isActive = selectedGuildId === guild.id
+ }
+
+ const handleClick = (guildId: number) => {
+ if (isAnswerSubmitted) return
+ onSelect(guildId)
+ }
+
+ return (
+
+ )
+}
+
+export default AnswerButton
diff --git a/src/components/guess-the-guild/guess-name/components/SkeletonGuessName.tsx b/src/components/guess-the-guild/guess-name/components/SkeletonGuessName.tsx
new file mode 100644
index 000000000..cdea0deb4
--- /dev/null
+++ b/src/components/guess-the-guild/guess-name/components/SkeletonGuessName.tsx
@@ -0,0 +1,13 @@
+import { Skeleton, SkeletonCircle, VStack } from "@chakra-ui/react"
+
+const SkeletonGuessName = () => (
+
+
+
+
+
+
+
+)
+
+export default SkeletonGuessName
diff --git a/src/components/guess-the-guild/guess-the-guild-types.tsx b/src/components/guess-the-guild/guess-the-guild-types.tsx
new file mode 100644
index 000000000..eb210ff58
--- /dev/null
+++ b/src/components/guess-the-guild/guess-the-guild-types.tsx
@@ -0,0 +1,25 @@
+import { GuildBase } from "types"
+
+export enum GameMode {
+ GuessNameMode,
+ AssignLogosMode,
+}
+
+export enum GameState {
+ Start,
+ Game,
+ End,
+}
+
+export type GameModeProps = {
+ guilds: GuildBase[]
+ onNext: () => void
+ onExit: () => void
+ onCorrect: () => void
+}
+
+export enum Difficulty {
+ Easy,
+ Medium,
+ Hard,
+}
diff --git a/src/components/guess-the-guild/hooks/useGameLogic.tsx b/src/components/guess-the-guild/hooks/useGameLogic.tsx
new file mode 100644
index 000000000..c06c76934
--- /dev/null
+++ b/src/components/guess-the-guild/hooks/useGameLogic.tsx
@@ -0,0 +1,81 @@
+import useLocalStorage from "hooks/useLocalStorage"
+import { useEffect, useState } from "react"
+import { Difficulty, GameMode, GameState } from "../guess-the-guild-types"
+
+// The amount of points that a right answer in a game mode yields
+export const GAME_MODE_POINTS: Record = {
+ [GameMode.GuessNameMode]: 1,
+ [GameMode.AssignLogosMode]: 2,
+}
+
+// The earned points get multiplied by this factor depending upon the difficulty
+export const DIFFICULTY_MULTIPLIERS: Record = {
+ [Difficulty.Easy]: 1,
+ [Difficulty.Medium]: 2,
+ [Difficulty.Hard]: 3,
+}
+
+const useGameLogic = () => {
+ const [savedHighscore, setSavedHighscore] = useLocalStorage("highscore", 0)
+ const [score, setScore] = useState(0)
+ const [highscore, setHighscore] = useState(0)
+ const [round, setRound] = useState(0)
+ const [gameState, setGameState] = useState(GameState.Start)
+ const [gameMode, setGameMode] = useState(GameMode.GuessNameMode)
+ const [difficulty, setDifficulty] = useState(Difficulty.Easy)
+
+ useEffect(() => {
+ setHighscore(savedHighscore)
+ }, [])
+
+ const addPoints = () => {
+ const pointsToAdd =
+ GAME_MODE_POINTS[gameMode] * DIFFICULTY_MULTIPLIERS[difficulty]
+ const updatedPoints = score + pointsToAdd
+ setScore(updatedPoints)
+ updateHighscore(updatedPoints)
+ }
+
+ const updateHighscore = (updatedPoints: number) => {
+ if (updatedPoints > highscore) {
+ setSavedHighscore(updatedPoints)
+ }
+ }
+
+ const restartGame = () => {
+ setScore(0)
+ setRound(0)
+ setHighscore(savedHighscore)
+ setGameState(GameState.Start)
+ }
+
+ const startGame = () => {
+ setGameState(GameState.Game)
+ selectGameMode()
+ }
+
+ const selectGameMode = () => {
+ if (Math.random() >= 0.5) {
+ setGameMode(GameMode.AssignLogosMode)
+ } else {
+ setGameMode(GameMode.GuessNameMode)
+ }
+ }
+
+ const endGame = () => {
+ setGameState(GameState.End)
+ }
+
+ const nextGame = () => {
+ selectGameMode()
+ setRound(round + 1)
+ }
+
+ return {
+ state: { score, highscore, round, difficulty, gameMode, gameState },
+ transition: { startGame, endGame, restartGame, nextGame },
+ action: { setDifficulty, addPoints },
+ }
+}
+
+export default useGameLogic
diff --git a/src/pages/guess-the-guild.tsx b/src/pages/guess-the-guild.tsx
new file mode 100644
index 000000000..a8f47c9d3
--- /dev/null
+++ b/src/pages/guess-the-guild.tsx
@@ -0,0 +1,181 @@
+import { Container, useBreakpointValue, useColorModeValue } from "@chakra-ui/react"
+import Card from "components/common/Card"
+import Layout from "components/common/Layout"
+import EndGame from "components/guess-the-guild/EndGame"
+import StartGame from "components/guess-the-guild/StartGame"
+import AssignLogos from "components/guess-the-guild/assign-logos/AssignLogos"
+import SkeletonAssignLogos from "components/guess-the-guild/assign-logos/components/SkeletonAssignLogos"
+import ScoreIndicator from "components/guess-the-guild/components/ScoreIndicator"
+import GuessName from "components/guess-the-guild/guess-name/GuessName"
+import SkeletonGuessName from "components/guess-the-guild/guess-name/components/SkeletonGuessName"
+import {
+ Difficulty,
+ GameMode,
+ GameState,
+} from "components/guess-the-guild/guess-the-guild-types"
+import useGameLogic from "components/guess-the-guild/hooks/useGameLogic"
+import useShowErrorToast from "hooks/useShowErrorToast"
+import React, { useEffect, useState } from "react"
+import useSWR from "swr"
+import { GuildBase } from "types"
+
+// The number of guilds to fetch from the API for each difficulty level
+export const DIFFICULTY_GUILD_POOL_SIZE: Record = {
+ [Difficulty.Easy]: 100,
+ [Difficulty.Medium]: 500,
+ [Difficulty.Hard]: 1000,
+}
+
+const GuessTheGuild = (): JSX.Element => {
+ const gameLogic = useGameLogic()
+
+ const bgColor = useColorModeValue("var(--chakra-colors-gray-800)", "#37373a")
+ const bgOpacity = useColorModeValue(0.06, 0.1)
+ const bgLinearPercentage = useBreakpointValue({ base: "50%", sm: "55%" })
+
+ const apiUrl = `/v2/guilds?sort=members&limit=${
+ DIFFICULTY_GUILD_POOL_SIZE[gameLogic.state.difficulty]
+ }`
+
+ const [guilds, setGuilds] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+
+ const { data: allGuilds, error } = useSWR(apiUrl, {
+ revalidateOnFocus: false,
+ refreshInterval: 86400000, // 24 hours
+ })
+
+ const selectGuilds = () => {
+ if (allGuilds) setGuilds(allGuilds.sort(() => 0.5 - Math.random()).slice(0, 4))
+ }
+
+ useEffect(() => {
+ if (allGuilds) {
+ const guildsCopy = allGuilds.filter((guild) => guild.imageUrl)
+ setGuilds(guildsCopy.sort(() => 0.5 - Math.random()).slice(0, 4))
+ setIsLoading(false)
+ } else {
+ setIsLoading(true)
+ }
+ }, [allGuilds])
+
+ const showErrorToast = useShowErrorToast()
+
+ useEffect(() => {
+ if (error) showErrorToast("Failed to load guilds! Please try again later.")
+ }, [error, showErrorToast])
+
+ const handleStart = () => {
+ selectGuilds()
+ gameLogic.transition.startGame()
+ }
+
+ const handleNext = () => {
+ selectGuilds()
+ gameLogic.transition.nextGame()
+ }
+
+ const handleDifficultyChange = (diff: Difficulty) => {
+ gameLogic.action.setDifficulty(diff)
+ }
+
+ return (
+ <>
+
+
+ {/* Show points indicator when the game is on */}
+ {gameLogic.state.gameState === GameState.Game && (
+
+ )}
+
+
+ {/* Start Screen */}
+ {gameLogic.state.gameState === GameState.Start && (
+
+ )}
+
+ {/* Game Screen */}
+ {gameLogic.state.gameState === GameState.Game && (
+
+ {/* Name Guessing Mode */}
+ {gameLogic.state.gameMode === GameMode.AssignLogosMode && (
+ <>
+ {isLoading && }
+ {!isLoading && (
+ <>
+ gameLogic.transition.endGame()}
+ onCorrect={() => gameLogic.action.addPoints()}
+ />
+ >
+ )}
+ >
+ )}
+
+ {/* Assign Logo Mode */}
+ {gameLogic.state.gameMode === GameMode.GuessNameMode && (
+ <>
+ {isLoading && }
+ {!isLoading && (
+ <>
+ gameLogic.transition.endGame()}
+ onCorrect={() => gameLogic.action.addPoints()}
+ />
+ >
+ )}
+ >
+ )}
+
+ )}
+
+ {/* End Screen */}
+ {gameLogic.state.gameState === GameState.End && (
+
+ )}
+
+
+
+ >
+ )
+}
+
+export default GuessTheGuild
diff --git a/src/utils/shuffleArray.ts b/src/utils/shuffleArray.ts
new file mode 100644
index 000000000..2c3fd4db3
--- /dev/null
+++ b/src/utils/shuffleArray.ts
@@ -0,0 +1,2 @@
+export const shuffleArray = (array: any[]) =>
+ [...array].sort(() => 0.5 - Math.random())