diff --git a/.prettierrc.js b/.prettierrc.js index 65e18f5ff..97139a108 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,4 +7,5 @@ module.exports = { bracketSpacing: true, arrowParens: "avoid", htmlWhitespaceSensitivity: "ignore", + endOfLine: 'auto' }; diff --git a/README.md b/README.md index 9b90842c4..2c442bea5 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,10 @@ https://skypro-web-developer.github.io/react-memo/ Запускает eslint проверку кода, эта же команда запускается перед каждым коммитом. Если не получается закоммитить, попробуйте запустить эту команду и исправить все ошибки и предупреждения. + +### Оценочное время выполнения + +20 часов + +### Фактическое время выполнения +13ч \ No newline at end of file diff --git a/public/assets/fonts/Poppins-Regular.woff2 b/public/assets/fonts/Poppins-Regular.woff2 new file mode 100644 index 000000000..4aae28cf1 Binary files /dev/null and b/public/assets/fonts/Poppins-Regular.woff2 differ diff --git a/public/assets/fonts/Roboto-Regular.woff2 b/public/assets/fonts/Roboto-Regular.woff2 new file mode 100644 index 000000000..30370cf27 Binary files /dev/null and b/public/assets/fonts/Roboto-Regular.woff2 differ diff --git a/src/api.js b/src/api.js new file mode 100644 index 000000000..606a7f53c --- /dev/null +++ b/src/api.js @@ -0,0 +1,27 @@ +const URL = "https://wedev-api.sky.pro/api/leaderboard"; + +export async function getLeaders() { + const response = await fetch(URL); + + if (response.status !== 200) { + throw new Error("Ошибка"); + } + const data = await response.json(); + return data.leaders; +} + +export async function postLeader(data) { + try { + const response = await fetch("https://wedev-api.sky.pro/api/leaderboard", { + method: "POST", + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error(`Ошибка: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error("Ошибка при отправке данных:", error); + throw error; + } +} diff --git a/src/components/Cards/Cards.jsx b/src/components/Cards/Cards.jsx index 7526a56c8..464596c40 100644 --- a/src/components/Cards/Cards.jsx +++ b/src/components/Cards/Cards.jsx @@ -1,10 +1,12 @@ import { shuffle } from "lodash"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { generateDeck } from "../../utils/cards"; import styles from "./Cards.module.css"; import { EndGameModal } from "../../components/EndGameModal/EndGameModal"; import { Button } from "../../components/Button/Button"; import { Card } from "../../components/Card/Card"; +import { LightContext } from "../../context/easyMode"; +// import { useNavigate } from "react-router-dom"; // Игра закончилась const STATUS_LOST = "STATUS_LOST"; @@ -41,8 +43,12 @@ function getTimerValue(startDate, endDate) { * previewSeconds - сколько секунд пользователь будет видеть все карты открытыми до начала игры */ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { + const { isLight, tries, setTries } = useContext(LightContext); // В cards лежит игровое поле - массив карт и их состояние открыта\закрыта const [cards, setCards] = useState([]); + + const [playerLost, setPlayerLost] = useState(false); + // Текущий статус игры const [status, setStatus] = useState(STATUS_PREVIEW); @@ -68,7 +74,12 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { setTimer(getTimerValue(startDate, null)); setStatus(STATUS_IN_PROGRESS); } + // const navigate = useNavigate(); + function resetGame() { + // navigate("/"); + setTries(isLight ? 3 : 1); + setPlayerLost(false); setGameStartDate(null); setGameEndDate(null); setTimer(getTimerValue(null, null)); @@ -77,16 +88,26 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { /** * Обработка основного действия в игре - открытие карты. - * После открытия карты игра может пепереходит в следующие состояния + * После открытия карты игра может переходить в следующие состояния * - "Игрок выиграл", если на поле открыты все карты * - "Игрок проиграл", если на поле есть две открытые карты без пары * - "Игра продолжается", если не случилось первых двух условий */ + + useEffect(() => { + if (tries === 0) setPlayerLost(true); + }, [tries, playerLost]); + + useEffect(() => { + if (playerLost) finishGame(STATUS_LOST); + }, [playerLost]); + const openCard = clickedCard => { // Если карта уже открыта, то ничего не делаем if (clickedCard.open) { return; } + // Игровое поле после открытия кликнутой карты const nextCards = cards.map(card => { if (card.id !== clickedCard.id) { @@ -123,13 +144,37 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { return false; }); - const playerLost = openCardsWithoutPair.length >= 2; + function tryLost() { + if (openCardsWithoutPair.length === 2) { + setTries(tries - 1); + setTimeout(() => { + setCards( + cards.reduce((acc, card) => { + if (card.id === clickedCard.id) { + return [...acc, { ...card, open: false }]; + } + return [...acc, card]; + }, []), + ); + setCards( + cards.reduce((acc, card) => { + const previousCard = openCardsWithoutPair.find(item => item.id !== clickedCard.id); + if (card.id === previousCard.id) { + return [...acc, { ...card, open: false }]; + } + return [...acc, card]; + }, []), + ); + }, 1000); + } + } + tryLost(); // "Игрок проиграл", т.к на поле есть две открытые карты без пары - if (playerLost) { - finishGame(STATUS_LOST); - return; - } + // if (lost) { + // finishGame(STATUS_LOST); + // return; + // } // ... игра продолжается }; @@ -195,9 +240,13 @@ export function Cards({ pairsCount = 3, previewSeconds = 5 }) { )} - {status === STATUS_IN_PROGRESS ? : null} + {status === STATUS_IN_PROGRESS ? ( +
+ + {isLight &&

Осталось попыток: {tries}

} +
+ ) : null} -
{cards.map(card => ( { + if (isWon && !isLight && pairsCount >= 9) { + const checkLeaderboard = async () => { + try { + const leaders = await getLeaders(); + if (Array.isArray(leaders) && leaders.length >= 10) { + const sortedLeaders = leaders.sort((a, b) => a.time - b.time); + const slowestTimeInTopTen = sortedLeaders[9].time; + if (addPlayer.time < slowestTimeInTopTen) { + setShouldAddToLeaderboard(true); + } + } else { + setShouldAddToLeaderboard(true); + } + } catch (error) { + console.error("Ошибка при проверке лидерборда:", error); + } + }; + checkLeaderboard(); + } + }, [isWon, isLight, pairsCount, addPlayer.time]); + + const title = isWon + ? shouldAddToLeaderboard + ? "Поздравляю, вы попали в Лидерборд!" + : "Вы выиграли!" + : "Вы проиграли!"; const imgSrc = isWon ? celebrationImageUrl : deadImageUrl; + const imgAlt = isWon ? "celebration emoji" : "dead emoji"; + + const handleLeaderboardRedirect = async e => { + e.preventDefault(); + if (isWon && shouldAddToLeaderboard) { + try { + await postLeader(addPlayer); + } catch (error) { + console.error("Ошибка при добавлении игрока:", error); + } + } + navigate("/leaderboard"); + }; - const imgAlt = isWon ? "celebration emodji" : "dead emodji"; + const handleKeyDown = e => { + if (e.key === "Enter") { + e.preventDefault(); + handleLeaderboardRedirect(e); + } + }; return ( -
- {imgAlt} -

{title}

-

Затраченное время:

-
- {gameDurationMinutes.toString().padStart("2", "0")}.{gameDurationSeconds.toString().padStart("2", "0")} +
+
+ {imgAlt} +

{title}

+ {shouldAddToLeaderboard && ( + setAddPlayer({ ...addPlayer, name: e.target.value })} + onKeyDown={handleKeyDown} + className={styles.input} + placeholder="Введите имя" + type="text" + value={addPlayer.name} + /> + )} +

Затраченное время:

+
+ {`${Math.floor(gameDurationSeconds / 60) + .toString() + .padStart(2, "0")}:${(gameDurationSeconds % 60).toString().padStart(2, "0")}`} +
+ +
- - -
+ ); } diff --git a/src/components/EndGameModal/EndGameModal.module.css b/src/components/EndGameModal/EndGameModal.module.css index 9368cb8b5..1f400fef8 100644 --- a/src/components/EndGameModal/EndGameModal.module.css +++ b/src/components/EndGameModal/EndGameModal.module.css @@ -1,6 +1,6 @@ .modal { width: 480px; - height: 459px; + height: 634px; border-radius: 12px; background: #c2f5ff; display: flex; @@ -16,6 +16,7 @@ } .title { + text-align: center; color: #004980; font-variant-numeric: lining-nums proportional-nums; font-family: StratosSkyeng; @@ -49,3 +50,29 @@ margin-bottom: 40px; } +.input { + border-radius: 10px; + border: none; + margin-bottom: 30px; + width: 276px; + height: 45px; + text-align: center; +} +input::placeholder{ + color: #999999; + font-weight: 400; + font-size: 24px; +} + +.btnLeaderBoard{ + padding-top: 20px; + background: none; + border: none; + text-decoration: underline; + cursor: pointer; + font-size: 18px; +} +.btnLeaderBoard:hover{ + transform: scale(1.05); + color: rgb(113, 113, 255); +} \ No newline at end of file diff --git a/src/context/PairsCountContext.jsx b/src/context/PairsCountContext.jsx new file mode 100644 index 000000000..554d84773 --- /dev/null +++ b/src/context/PairsCountContext.jsx @@ -0,0 +1,12 @@ +import { createContext, useContext, useState } from "react"; + +const PairsCountContext = createContext(); + +export function usePairsCount() { + return useContext(PairsCountContext); +} + +export function PairsCountProvider({ children, initialPairsCount }) { + const [pairsCount, setPairsCount] = useState(initialPairsCount); + return {children}; +} diff --git a/src/context/easyMode.js b/src/context/easyMode.js new file mode 100644 index 000000000..add86d8c6 --- /dev/null +++ b/src/context/easyMode.js @@ -0,0 +1,14 @@ +import { createContext, useEffect, useState } from "react"; + +export const LightContext = createContext(); + +export const LightProvider = ({ children }) => { + const [isLight, setIsLight] = useState(true); + const [tries, setTries] = useState(3); + + useEffect(() => { + isLight ? setTries(3) : setTries(1); + }, [isLight]); + + return {children}; +}; diff --git a/src/index.js b/src/index.js index f689c5f0b..ce8753683 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,13 @@ import ReactDOM from "react-dom/client"; import "./index.css"; import { RouterProvider } from "react-router-dom"; import { router } from "./router"; +import { LightProvider } from "./context/easyMode"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( - + + + , ); diff --git a/src/pages/GamePage/GamePage.jsx b/src/pages/GamePage/GamePage.jsx index a4be871db..0845d0d99 100644 --- a/src/pages/GamePage/GamePage.jsx +++ b/src/pages/GamePage/GamePage.jsx @@ -1,13 +1,13 @@ import { useParams } from "react-router-dom"; - import { Cards } from "../../components/Cards/Cards"; +import { PairsCountProvider } from "../../context/PairsCountContext"; export function GamePage() { const { pairsCount } = useParams(); - + const parsedPairsCount = parseInt(pairsCount, 10); return ( - <> - - + + + ); } diff --git a/src/pages/LeaderBoardPage/LeaderBoardPage.jsx b/src/pages/LeaderBoardPage/LeaderBoardPage.jsx new file mode 100644 index 000000000..5008685e8 --- /dev/null +++ b/src/pages/LeaderBoardPage/LeaderBoardPage.jsx @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import styles from "../LeaderBoardPage/LeaderBoardPage.module.css"; +import { getLeaders } from "../../api"; + +function formatTime(seconds) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; +} + +export function LeaderBoard() { + const [leaders, setLeaders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchLeaders = async () => { + try { + const data = await getLeaders(); + if (Array.isArray(data)) { + const sortedLeaders = data.sort((a, b) => a.time - b.time).slice(0, 10); + setLeaders(sortedLeaders); + } else { + setError("Неверный формат данных"); + } + } catch (err) { + console.error("Ошибка при загрузке данных:", err); + setError("Ошибка при загрузке данных"); + } finally { + setLoading(false); + } + }; + + fetchLeaders(); + }, []); + + if (loading) { + return

Загрузка...

; + } + + if (error) { + return

{error}

; + } + + return ( +
+
+

Лидерборд

+ + + +
+
    +
  • +

    Позиция

    +

    Пользователь

    +

    Время

    +
  • + {leaders.map((leader, index) => ( +
  • +

    #{index + 1}

    +

    {leader.name}

    +

    {formatTime(leader.time)}

    {} +
  • + ))} +
+
+ ); +} diff --git a/src/pages/LeaderBoardPage/LeaderBoardPage.module.css b/src/pages/LeaderBoardPage/LeaderBoardPage.module.css new file mode 100644 index 000000000..15d3a38c8 --- /dev/null +++ b/src/pages/LeaderBoardPage/LeaderBoardPage.module.css @@ -0,0 +1,80 @@ +@font-face { + font-family: "Roboto"; + src: url("/public/assets/fonts/Roboto-Regular.woff2") format("woff2"); + src: url("/public/assets/fonts/Poppins-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; +} + +.header { + display: flex; + flex-direction: row; + justify-content: space-around; + margin-top: 50px; +} + +.title { + font-family: Roboto; + font-size: 24px; + font-weight: 400; + line-height: 32px; + text-align: left; + color: #fff; +} + +.btn { + width: 246px; + height: 50px; + border-radius: 12px; + border: none; + background-color: rgba(122, 193, 0, 1); + font-family: Roboto; + font-size: 24px; + font-weight: 400; + line-height: 32px; + text-align: center; + color: #fff; + cursor: pointer; +} + +.btn:hover { + background: #7ac100cc; +} + +.listHeader { + width: 944px; + height: 64px; + border-radius: 15px; + background-color: #fff; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-top: 40px; + padding-left: 20px; + padding-right: 20px; +} + +.listHeaderText { + font-family: Poppins; + font-size: 24px; + font-weight: 400; + line-height: 32px; + text-align: left; + color: rgba(153, 153, 153, 1); +} + +.listText { + font-family: Poppins; + font-size: 24px; + font-weight: 400; + line-height: 32px; + text-align: left; + color: rgba(0, 0, 0, 1); +} + +.flex { + display: flex; + flex-direction: column; + align-items: center; +} diff --git a/src/pages/SelectLevelPage/SelectLevelPage.jsx b/src/pages/SelectLevelPage/SelectLevelPage.jsx index 758942e51..56e52ba17 100644 --- a/src/pages/SelectLevelPage/SelectLevelPage.jsx +++ b/src/pages/SelectLevelPage/SelectLevelPage.jsx @@ -1,7 +1,10 @@ import { Link } from "react-router-dom"; import styles from "./SelectLevelPage.module.css"; +import { useContext } from "react"; +import { LightContext } from "../../context/easyMode"; export function SelectLevelPage() { + const { isLight, setIsLight } = useContext(LightContext); return (
@@ -23,6 +26,19 @@ export function SelectLevelPage() { + + + Перейти к лидерборду +
); diff --git a/src/pages/SelectLevelPage/SelectLevelPage.module.css b/src/pages/SelectLevelPage/SelectLevelPage.module.css index 390ac0def..91aaadfec 100644 --- a/src/pages/SelectLevelPage/SelectLevelPage.module.css +++ b/src/pages/SelectLevelPage/SelectLevelPage.module.css @@ -62,3 +62,57 @@ .levelLink:visited { color: #0080c1; } + +.checkbox { + position: relative; + width: 24px; + height: 13px; + border-radius: 100px; + background: #0080c1; + outline: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + &::before { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 11px; + height: 11px; + border-radius: 50%; + background-color: #fff; + transition: 0.5s; + } + &:checked::before { + left: 12px; + background: #fff; + } + &:checked { + background-color: #046d2c; + } +} + +.checkboxLabel { + color: #004980; + text-align: center; + font-variant-numeric: lining-nums proportional-nums; + font-family: StratosSkyeng; + font-size: 20px; + font-style: normal; + font-weight: 400; + line-height: 48px; +} + +.leaderboard { + font-family: Roboto; + font-size: 18px; + font-weight: 400; + line-height: 32px; + text-align: left; + color: #004980; +} +.leaderboard:visited { + color: #004980; +} diff --git a/src/router.js b/src/router.js index da6e94b51..7fe177892 100644 --- a/src/router.js +++ b/src/router.js @@ -1,6 +1,7 @@ import { createBrowserRouter } from "react-router-dom"; import { GamePage } from "./pages/GamePage/GamePage"; import { SelectLevelPage } from "./pages/SelectLevelPage/SelectLevelPage"; +import { LeaderBoard } from "./pages/LeaderBoardPage/LeaderBoardPage"; export const router = createBrowserRouter( [ @@ -12,6 +13,10 @@ export const router = createBrowserRouter( path: "/game/:pairsCount", element: , }, + { + path: "/leaderboard", + element: , + }, ], /** * basename нужен для корректной работы в gh pages