Skip to content

Commit 4102c63

Browse files
author
Jonay Pelluz
committed
feature: accent fixer game
1 parent 4c68f8c commit 4102c63

12 files changed

Lines changed: 333 additions & 96 deletions

File tree

components/Header.tsx

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const Head: React.FC = () => {
1818
const { locale, wordOfTheDay, gameLevel, currentRoutes, generalLoading, isLoading } =
1919
useWordsContext();
2020
useDailyWord();
21-
const { selectLevel } = useLevelLoader();
21+
const { selectLevel, loadAllLevels } = useLevelLoader();
2222
const [navOpen, setNavOpen] = useState(false);
2323
const [levelOpen, setLevelOpen] = useState(false);
2424

@@ -38,6 +38,11 @@ const Head: React.FC = () => {
3838
setLevelOpen(false);
3939
};
4040

41+
const handleLoadLevels = () => {
42+
loadAllLevels();
43+
setLevelOpen(false);
44+
};
45+
4146
return (
4247
<header className="header">
4348
<Link href={currentRoutes.home} className="logo">
@@ -66,30 +71,43 @@ const Head: React.FC = () => {
6671
</nav>
6772
<div className="header-right">
6873
<div className="level-selector">
69-
<button
70-
className={`level-selector-toggle${gameLevel ? ' has-level' : ''}`}
71-
onClick={() => setLevelOpen((o) => !o)}
72-
aria-expanded={levelOpen}
73-
>
74-
{gameLevel
75-
? levelTranslations[gameLevel]
76-
: intl.formatMessage({ id: 'homeChoseLevel' })}
77-
{(generalLoading || isLoading) && (
78-
<span className="level-selector-spinner" aria-hidden="true" />
79-
)}
80-
</button>
81-
{levelOpen && (
82-
<div className="level-selector-dropdown">
83-
{LevelsConfig.map((level: LevelConfig) => (
84-
<button
85-
key={level.level}
86-
className={`level-option btn-${level.level}${gameLevel === level.level ? ' selected' : ''}`}
87-
onClick={() => handleLevelSelect(level.level)}
88-
>
89-
{levelTranslations[level.level]}
90-
</button>
91-
))}
92-
</div>
74+
{!gameLevel ? (
75+
<button
76+
className="level-selector-toggle"
77+
onClick={handleLoadLevels}
78+
disabled={isLoading}
79+
>
80+
<FormattedMessage id="homeLoadLevels" />
81+
{isLoading && (
82+
<span className="level-selector-spinner" aria-hidden="true" />
83+
)}
84+
</button>
85+
) : (
86+
<>
87+
<button
88+
className="level-selector-toggle has-level"
89+
onClick={() => setLevelOpen((o) => !o)}
90+
aria-expanded={levelOpen}
91+
>
92+
{levelTranslations[gameLevel]}
93+
{(generalLoading || isLoading) && (
94+
<span className="level-selector-spinner" aria-hidden="true" />
95+
)}
96+
</button>
97+
{levelOpen && (
98+
<div className="level-selector-dropdown">
99+
{LevelsConfig.map((level: LevelConfig) => (
100+
<button
101+
key={level.level}
102+
className={`level-option btn-${level.level}${gameLevel === level.level ? ' selected' : ''}`}
103+
onClick={() => handleLevelSelect(level.level)}
104+
>
105+
{levelTranslations[level.level]}
106+
</button>
107+
))}
108+
</div>
109+
)}
110+
</>
93111
)}
94112
</div>
95113
{wordOfTheDay && (

components/HomeContent.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ const mainImageArray: string[] = [
2222

2323
const HomeContent: React.FC = () => {
2424
const intl = useIntl();
25-
const { error, gameLevel, hydrated } = useWordsContext();
26-
const { selectLevel } = useLevelLoader();
25+
const { error, gameLevel, isLoading, hydrated, generalLoading } = useWordsContext();
26+
const { selectLevel, loadAllLevels } = useLevelLoader();
2727
// Keep the random hero image stable across renders. Picking it inside a
2828
// useState initializer would re-randomize between server and client; this
2929
// effect picks it once after mount to avoid hydration mismatch.
@@ -36,6 +36,10 @@ const HomeContent: React.FC = () => {
3636
selectLevel(level);
3737
};
3838

39+
const handleLoadAllClick = () => {
40+
loadAllLevels();
41+
};
42+
3943
// General (initial) load: on first hydration, if the user has a stored
4044
// level but the word-group caches have expired, reload them through the
4145
// same serialized level-load chain used by level switches. Runs once.
@@ -67,7 +71,7 @@ const HomeContent: React.FC = () => {
6771
subtitle={intl.formatMessage({ id: 'mainDescription' })}
6872
styles={{ border: '1px solid #000' }}
6973
/>
70-
<LevelList handlePopulateDBClick={handlePopulateDBClick} gameLevel={gameLevel} />
74+
<LevelList handlePopulateDBClick={handlePopulateDBClick} handleLoadAllClick={handleLoadAllClick} gameLevel={gameLevel} isLoading={isLoading || generalLoading} hydrated={hydrated} />
7175
<Games />
7276
</MainLayout>
7377
);

components/LevelList.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
'use client';
22

33
import React, { useState } from 'react';
4-
import { useIntl } from 'react-intl';
4+
import { FormattedMessage, useIntl } from 'react-intl';
55
import LevelsConfig from '@config/LevelConfig';
66
import { LevelConfig } from '@models/types';
77

88
interface LevelListProps {
99
handlePopulateDBClick: (level: string) => void;
10+
handleLoadAllClick: () => void;
1011
gameLevel: string | null;
12+
isLoading: boolean;
13+
hydrated: boolean;
1114
}
1215

13-
const LevelList: React.FC<LevelListProps> = ({ handlePopulateDBClick, gameLevel }) => {
16+
const LevelList: React.FC<LevelListProps> = ({
17+
handlePopulateDBClick,
18+
handleLoadAllClick,
19+
gameLevel,
20+
isLoading,
21+
hydrated,
22+
}) => {
1423
const intl = useIntl();
1524
const [isOpen, setIsOpen] = useState(gameLevel === null);
1625

@@ -20,9 +29,24 @@ const LevelList: React.FC<LevelListProps> = ({ handlePopulateDBClick, gameLevel
2029
advanced: intl.formatMessage({ id: 'levelAdvanced' }),
2130
};
2231

23-
const summaryLabel = gameLevel
24-
? `${intl.formatMessage({ id: 'homeLevel' })} ${levelTranslations[gameLevel]}`
25-
: intl.formatMessage({ id: 'homeChoseLevel' });
32+
if (!hydrated) return <div className="level-wrapper" />;
33+
34+
if (!gameLevel) {
35+
return (
36+
<div className="level-wrapper">
37+
<button
38+
className="btn-primary level-load-btn"
39+
onClick={handleLoadAllClick}
40+
disabled={isLoading}
41+
>
42+
<FormattedMessage id="homeLoadLevels" />
43+
{isLoading && <span className="level-selector-spinner" aria-hidden="true" />}
44+
</button>
45+
</div>
46+
);
47+
}
48+
49+
const summaryLabel = `${intl.formatMessage({ id: 'homeLevel' })} ${levelTranslations[gameLevel]}`;
2650

2751
return (
2852
<div className="level-wrapper">
@@ -33,6 +57,7 @@ const LevelList: React.FC<LevelListProps> = ({ handlePopulateDBClick, gameLevel
3357
aria-expanded={isOpen}
3458
>
3559
{summaryLabel}
60+
{isLoading && <span className="level-selector-spinner" aria-hidden="true" />}
3661
</button>
3762
{isOpen && (
3863
<div className="level-content">
@@ -41,7 +66,7 @@ const LevelList: React.FC<LevelListProps> = ({ handlePopulateDBClick, gameLevel
4166
key={idx}
4267
onClick={() => handlePopulateDBClick(level.level)}
4368
className={`btn-${level.level} btn-levels ${
44-
gameLevel && gameLevel === level.level ? 'selected' : ''
69+
gameLevel === level.level ? 'selected' : ''
4570
}`}
4671
>
4772
{/* eslint-disable-next-line @next/next/no-img-element */}

config/translations/General.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,28 @@ import { LoadingMessagesType } from '@models/types';
33

44
const LoadingMessages: LoadingMessagesType = {
55
es: [
6-
'Oops, las letras se han rebelado. ¡Algo ha ido mal!',
7-
'Parece que nuestras palabras están jugando al escondite. ¡Error detectado!',
8-
'¡Vaya! Alguien derramó café en el código. Estamos arreglándolo.',
9-
'Las tildes se han escapado, y con ellas, la estabilidad del sistema. Error en proceso.',
10-
'Estamos experimentando una tormenta de ideas... y de errores. Por favor, espera.',
11-
'Error en el sistema. Las letras están bailando salsa en vez de trabajar.',
12-
'Algo se ha torcido... posiblemente sean las eses. Trabajando para solucionarlo.',
13-
'Nuestros puntos y comas hicieron una pausa demasiado larga. Error encontrado.',
14-
'El alfabeto ha decidido tomarse un descanso. Error inesperado.',
15-
'Las palabras están haciendo huelga. Nos disculpamos por este error técnico.',
6+
'Preparando las letras para ti...',
7+
'Ordenando el abecedario, un momento...',
8+
'Las palabras están tomando asiento, ya casi...',
9+
'Cargando el diccionario, paciencia...',
10+
'Las tildes se están colocando en su sitio...',
11+
'Reuniendo todas las vocales y consonantes...',
12+
'Organizando las palabras por orden alfabético...',
13+
'Llenando el tablero de letras, casi listo...',
14+
'Los niveles se están preparando para el juego...',
15+
'Un segundo, estamos afinando la ortografía...',
1616
],
1717
en: [
18-
'Oops, the letters have rebelled. Something has gone wrong!',
19-
'It seems our words are playing hide and seek. Error detected!',
20-
// prettier-ignore
21-
'Oops! Someone spilled coffee on the code. We\'re fixing it.',
22-
// prettier-ignore
23-
'The accent marks have escaped, and with them, the system\'s stability. Error in process.',
24-
'We are experiencing a brainstorm... and errors. Please wait.',
25-
'System error. The letters are dancing salsa instead of working.',
26-
// prettier-ignore
27-
'Something has gone wrong... possibly the S\'s. Working to solve it.',
28-
'Our semicolons took a pause that was too long. Error found.',
29-
'The alphabet has decided to take a break. Unexpected error.',
30-
'The words are on strike. We apologize for this technical error.',
18+
'Getting the letters ready for you...',
19+
'Sorting the alphabet, one moment...',
20+
'Words are taking their seats, almost there...',
21+
'Loading the dictionary, hang tight...',
22+
'Accent marks are finding their place...',
23+
'Gathering all the vowels and consonants...',
24+
'Arranging words in alphabetical order...',
25+
'Filling the board with letters, almost done...',
26+
'Levels are getting ready for the game...',
27+
'One second, fine-tuning the spelling...',
3128
],
3229
};
3330

@@ -78,6 +75,9 @@ const GeneralTranslations: Translations = {
7875
gameCrossWordGenerating: 'Generating crossword...',
7976
gameCrossWordComplete: 'Congratulations! You completed the crossword!',
8077
gameAccentNoAccent: 'No accent mark',
78+
gameAccentResultPerfect: 'Congratulations! No mistakes!',
79+
gameAccentResultFew: 'You only made {count} mistake(s)!',
80+
gameAccentResultMany: 'You should study a bit more!',
8181
gameQuizFinishedScore: 'You got {score} out of {total} correct!',
8282
headerHome: 'Home',
8383
headerGames: 'Games',
@@ -88,6 +88,7 @@ const GeneralTranslations: Translations = {
8888
headerWordOfTheDayUrl: 'https://www.dictionary.com/browse/',
8989
homeLevel: 'Level:',
9090
homeChoseLevel: 'Choose the level',
91+
homeLoadLevels: 'Load levels',
9192
incorrectWords: 'Incorrect words:',
9293
levelBeginner: 'Beginner',
9394
levelIntermediate: 'Intermediate',
@@ -140,6 +141,9 @@ const GeneralTranslations: Translations = {
140141
gameCrossWordGenerating: 'Generando crucigrama...',
141142
gameCrossWordComplete: '¡Enhorabuena! ¡Has completado el crucigrama!',
142143
gameAccentNoAccent: 'No lleva tilde',
144+
gameAccentResultPerfect: '¡Enhorabuena! ¡Sin ningún error!',
145+
gameAccentResultFew: '¡Solo has fallado {count} vez/veces!',
146+
gameAccentResultMany: '¡Deberías estudiar un poco más!',
143147
gameQuizFinishedScore: '¡Acertaste {score} de {total}!',
144148
headerHome: 'Inicio',
145149
headerGames: 'Juegos',
@@ -150,6 +154,7 @@ const GeneralTranslations: Translations = {
150154
headerWordOfTheDayUrl: 'https://dle.rae.es/',
151155
homeLevel: 'Nivel:',
152156
homeChoseLevel: 'Elige el nivel',
157+
homeLoadLevels: 'Cargar niveles',
153158
incorrectWords: 'Palabras incorrectas:',
154159
levelBeginner: 'Principiante',
155160
levelIntermediate: 'Intermedio',

games/accentFixer/UI.tsx

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type AccentFixerUIProps = {
1515
error: Error | null;
1616
countdown: number;
1717
gameLevel: string | null;
18+
isLevelLoading: boolean;
1819
showButton: boolean;
1920
challenges: AccentChallenge[] | null;
2021
currentIndex: number;
@@ -34,6 +35,7 @@ const UI: React.FC<AccentFixerUIProps> = ({
3435
error,
3536
countdown,
3637
gameLevel,
38+
isLevelLoading,
3739
showButton,
3840
challenges,
3941
currentIndex,
@@ -63,7 +65,7 @@ const UI: React.FC<AccentFixerUIProps> = ({
6365
title={gameConfig.title}
6466
subtitle={gameConfig.description}
6567
>
66-
{!gameLevel ? (
68+
{!gameLevel || isLevelLoading ? (
6769
<button className="btn-primary game-btn" disabled>
6870
<FormattedMessage id="gameSelectLevel" />
6971
</button>
@@ -88,6 +90,7 @@ const UI: React.FC<AccentFixerUIProps> = ({
8890
) : gameStarted && challenge ? (
8991
<>
9092
<div className="accent-fixer-score">{correctAnswers}</div>
93+
<p className="accent-fixer-word-label">{challenge.displayed}</p>
9194
<div className="accent-fixer-word">
9295
{challenge.displayed.split('').map((char, index) => {
9396
const isVowel = challenge.vowelIndices.includes(index);
@@ -120,21 +123,41 @@ const UI: React.FC<AccentFixerUIProps> = ({
120123
) : (
121124
<div className="results-wrapper">
122125
{error && <p className="load-error">{error.message}</p>}
123-
<div>
124-
<em className="results-title">
125-
<FormattedMessage id="incorrectWords" />
126-
</em>
127-
<strong className="results-title text-danger">
128-
{incorrectAnswers.length}
129-
</strong>
130-
</div>
131-
{incorrectAnswers.map(([displayed, original], index) => (
132-
<div key={index}>
133-
<span className="results-ko text-danger">{displayed}</span>
134-
{' → '}
135-
<strong className="results-ok text-success">{original}</strong>
136-
</div>
137-
))}
126+
{incorrectAnswers.length === 0 ? (
127+
<p className="results-verdict results-verdict--perfect">
128+
<FormattedMessage id="gameAccentResultPerfect" />
129+
</p>
130+
) : incorrectAnswers.length < 5 ? (
131+
<p className="results-verdict results-verdict--few">
132+
<FormattedMessage
133+
id="gameAccentResultFew"
134+
values={{ count: incorrectAnswers.length }}
135+
/>
136+
</p>
137+
) : (
138+
<p className="results-verdict results-verdict--many">
139+
<FormattedMessage id="gameAccentResultMany" />
140+
</p>
141+
)}
142+
{incorrectAnswers.length > 0 && (
143+
<>
144+
<div>
145+
<em className="results-title">
146+
<FormattedMessage id="incorrectWords" />
147+
</em>
148+
<strong className="results-title text-danger">
149+
{incorrectAnswers.length}
150+
</strong>
151+
</div>
152+
{incorrectAnswers.map(([displayed, original], index) => (
153+
<div key={index}>
154+
<span className="results-ko text-danger">{displayed}</span>
155+
{' → '}
156+
<strong className="results-ok text-success">{original}</strong>
157+
</div>
158+
))}
159+
</>
160+
)}
138161
</div>
139162
)}
140163
</div>

0 commit comments

Comments
 (0)