feat(frontend): offer the tutorial to newcomers and a practice game

The home page shows newcomers a banner to start the lessons; it goes away for
good after Not now, a lesson or a game. The lessons end with a practice game
against the Easy bot with hints on.
This commit is contained in:
Halit Aksoy 2026-10-16 14:55:51 +03:00
parent 9beb8ba7d4
commit d444447425
7 changed files with 103 additions and 1 deletions

View File

@ -0,0 +1,47 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { setHintsEnabled } from "@mancala/core";
import { Context } from "../context/context";
import Util from "../util/Util";
// The tutorial's last step: a game against the Easy bot with hints on (R3.1).
const PracticeGameCard: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const start = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
setHintsEnabled(context.storage, true);
navigate("/loby?bot=easy");
};
return (
<section className="practice panel">
<div>
<strong>{t("PracticeGame")}</strong>
<p>{t("PracticeGameText")}</p>
</div>
<button className="btn pri" onClick={start}>{t("PlayWithBot")}</button>
<style jsx>{`
.practice {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 18px 22px;
flex-shrink: 0;
}
strong {
font-size: 18px;
}
p {
margin: 4px 0 0;
color: var(--muted);
}
`}</style>
</section>
);
};
export default PracticeGameCard;

View File

@ -0,0 +1,47 @@
import * as React from "react";
import { FunctionComponent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { hideTutorialBanner, shouldShowTutorialBanner } from "@mancala/core";
import { Context } from "../../context/context";
// For newcomers: an offer to learn the game, until they open the lessons, play a game or say not now (R4).
const TutorialBanner: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [visible, setVisible] = useState(() => shouldShowTutorialBanner(context.storage));
if (!visible) return null;
return (
<section className="tbanner panel">
<strong>{t("NewToMangala")}</strong>
<div className="actions">
<button className="btn pri" onClick={() => navigate("/learn")}>{t("StartTutorial")}</button>
<button className="btn ghost" onClick={() => {
hideTutorialBanner(context.storage);
setVisible(false);
}}>{t("NotNow")}</button>
</div>
<style jsx>{`
.tbanner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 18px 22px;
flex-shrink: 0;
box-shadow: inset 0 0 0 2px var(--accent);
}
strong {
font-size: 18px;
}
.actions {
display: flex;
gap: 10px;
}
`}</style>
</section>
);
};
export default TutorialBanner;

View File

@ -7,6 +7,7 @@ import PageContainer from "../components/PageContainer";
import { PlusIcon } from "../components/Icons"; import { PlusIcon } from "../components/Icons";
import BotLevelMenu from "../components/BotLevelMenu"; import BotLevelMenu from "../components/BotLevelMenu";
import ActiveGameBanner from "../components/home/ActiveGameBanner"; import ActiveGameBanner from "../components/home/ActiveGameBanner";
import TutorialBanner from "../components/home/TutorialBanner";
import LiveGamesSection from "../components/home/LiveGamesSection"; import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection";
import PopularGamesSection from "../components/home/PopularGamesSection"; import PopularGamesSection from "../components/home/PopularGamesSection";
@ -58,6 +59,7 @@ const Home: FunctionComponent<{
</button> </button>
</AppHeader> </AppHeader>
<main className="wrap"> <main className="wrap">
<TutorialBanner context={context} />
{activeGame && authUser && ( {activeGame && authUser && (
<ActiveGameBanner <ActiveGameBanner
game={activeGame} game={activeGame}

View File

@ -6,6 +6,7 @@ import { getCompletedLessons, hideTutorialBanner, LESSON_RULES, Theme } from "@m
import AppHeader from "../components/headerbar/AppHeader"; import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer"; import PageContainer from "../components/PageContainer";
import { Context } from "../context/context"; import { Context } from "../context/context";
import PracticeGameCard from "../components/PracticeGameCard";
// The lessons in order, the ones done marked (docs/specs/tutorial, R2.6). // The lessons in order, the ones done marked (docs/specs/tutorial, R2.6).
const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => { const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
@ -31,6 +32,7 @@ const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
); );
})} })}
</ol> </ol>
<PracticeGameCard context={context} />
<Link className="rules-link" to="/rules">{t("HowToPlay")}</Link> <Link className="rules-link" to="/rules">{t("HowToPlay")}</Link>
</main> </main>
<style jsx>{` <style jsx>{`

View File

@ -12,6 +12,7 @@ import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer"; import PageContainer from "../components/PageContainer";
import BoardView from "../components/board/BoardView"; import BoardView from "../components/board/BoardView";
import { Context } from "../context/context"; import { Context } from "../context/context";
import PracticeGameCard from "../components/PracticeGameCard";
const YOU = "you"; const YOU = "you";
const OPPONENT = "opponent"; const OPPONENT = "opponent";
@ -130,6 +131,7 @@ const LessonPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ con
</> </>
)} )}
</div> </div>
{fulfilled && !next && <PracticeGameCard context={context} />}
{game && boardViewModel && ( {game && boardViewModel && (
<div className="lboard"> <div className="lboard">
<BoardView <BoardView

View File

@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import AppHeader from '../components/headerbar/AppHeader'; import AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer'; import PageContainer from '../components/PageContainer';
import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core'; import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core';
import { markGamePlayed } from '@mancala/core';
import BotLevelPicker from '../components/BotLevelPicker'; import BotLevelPicker from '../components/BotLevelPicker';
import { Context } from '../context/context'; import { Context } from '../context/context';
import { Theme } from '@mancala/core'; import { Theme } from '@mancala/core';
@ -35,6 +36,7 @@ const LobyPage: FunctionComponent<{
const onGameStart = (message: Object) => { const onGameStart = (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame; const newGame: CommonMancalaGame = message as CommonMancalaGame;
markGamePlayed(context.storage);
navigate(`/game/${newGame.id}`) navigate(`/game/${newGame.id}`)
} }

View File

@ -9,7 +9,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
game page. game page.
- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and - [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
reset, scripted opponent replies. reset, scripted opponent replies.
- [ ] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button. - [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`. - [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle. - [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle.
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope - [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope