desenvolvimento pre-lancamento

Commit inicial - add do repo privado para o repo NT

style: changes header's logo and colors

style: changes home page first session layout

feat: creates about us home page section

chore: creates home page section for whom

chore: creates student materails home page section

chore: creates teachers materials home page section

chore: creates teacher materials home page section

style: changes primary color

style: changes color at activities page

style: changes about page color

style: changes name to Decoda

fix: changes route to about page at footer

fix: changes background color

style: changes game page header colors

style: changes footer colors

chore: adds home page sections title

style: changes main font family to Lato

style: adds title font

fix: changes sizes to be more responsive for mobile

ajuste no build vercel

atualiza regras envio homol

Adiciona instrucoes de uso

add JupyterLite

fix solucao turtle

Add Mole Mash e Modal de Falhas

Add Progress Bar na pagina de Atividades

fix game name

chore: atualiza lockfile removendo vercel analytics

inclusão de efeito ao mudar de fase

add mecanismo de solução de fases em debug

vite config test

add BaseGame e refator do MoleMash

refatoração turtle

refatoração automato

refatoração automato

add tag

bug 1 e 2 automato

mostrar apenas games em homologação na pagina de atividades

aumentar timeout das fases finais do Turtle

fix bug scroll

add video

refactor semaforo

arrumar ordem das cores

add build docs

update vercel

update vercel

update vercel

update vercel

update vercel

add vercel jupyter

add vercel jupyter

fix deploy Vercel

fix deploy Vercel

fix deploy Vercel

add cripto

add cripto

refatoração

fix tour Mole Mash

.

remover arquivos de controle

chore: adds development tag for activity card

remover arquivos de status indevidamente versionados

atualizar cores nas atividades

add Quebra Cabeças

add Quebra Cabeças

add iniciativas

add Iniciativas

alteração de fotos pesadas

fix menu mobile

fix menu mobile

fix menu mobile

add Aspirador

update icons

update identidade visual documentação

update jupyter

add kernel python local

add kernel python local

add kernel python local

feat: add health check

feat: add primeiros passos

add letramento

mover letramento de lugar

update path games

update path games

fix: ajuste clique rapido no botão executar

remover dead code

fix: refactor: extract shared utilities for storage, phase unlock and mobile detection

stabilize context references and fix stale closure

extrair GameProgressContext do GameStateContext (SRP)

refactor(game): extrair usePhaser e useGameModals de GameBase + corrigir bugs descobertos

refactor(game): remove todos os aliases PT/EN duplicados

Remover aliases PT/EN da camada de modais

refactor + tests

security: add CodeSanitizer and integrate into GameInterpreter

- CodeSanitizer.js: 4 built-in rules (max_length, infinite_while,
  infinite_for, excessive_nesting) with pluggable extra rules
- GameInterpreter.executeCode: calls sanitizeCode() before js-interpreter,
  differentiates CodeSanitizationError (warn) from other errors (error)
- 21 unit tests for CodeSanitizer (100% coverage)
- 4 integration tests in GameInterpreter for sanitization paths

add CodeSanitizer

fix: fase 10 aspirador

fix: bug semaforo

teste

feat: add version

Ajusta a landing page para ficar mais próxima ao protótipo

ajusta raio da borda do botão de Acesse nosso Laboratório

pequenos ajustes de layout na página de iniciativas

atualiza tabela de jogos educativos com os jogos disponíveis atualmente

ajustados pequenos detalhes e informações do jogos na seção de guias pedagógicos

troca nome playground para laboratório e adiciona imagens do lab

adiciona documentação de conceitos básicos de programação

ajustado pequenos erros de digitação

adiciona tooltip com conceitos escondidos em hover na tag +N de conceitos

update docs dev

desativar tour

setup matriz MoleMash

setup matriz MoleMash

fix: link

update version

update docs

update docs

mudou o layout de quem somos

mudei as imgs dos icons e baixei o botao

centraliza titulo com imagem e ajusta sessão com gradiente vermelho-rosa

adiciona responsividade para a pagina quem somos

ajusta botão de conheça nossa história

ajustes

ajustes na home + add. teclado

update version

security

security

feat: add tapume para telas pequenas

v1.1.0

feat: decoda offline

feat: doc offline

offline

fix: ajustes para release

fix: navbar; config ordenação; versão

fix: rotas docs e jupyter para pwa

delete private files

Co-authored-by: Indra Araujo <indra.araujo.santos@gmail.com>
Co-authored-by: solange dos santos <sollangelive71@gmail.com>
This commit is contained in:
2025-10-29 21:30:14 -03:00
committed by Graciano
parent e24ee22b5a
commit 3da7d323e8
577 changed files with 121994 additions and 158 deletions

View File

@@ -0,0 +1,58 @@
import React from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { MonitorOff, Construction, House } from "lucide-react";
export default function DesktopOnlyTapume({
title = "Área disponível apenas no desktop",
message = "Esta experiência foi preparada para telas maiores. Acesse em um computador para aproveitar todos os recursos.",
homePath = "/",
homeLabel = "Voltar para Home",
}) {
const navigate = useNavigate();
return (
<section className="relative flex-1 min-h-0 overflow-hidden primary-gradient">
<div className="absolute inset-0 opacity-20 bg-[radial-gradient(circle_at_20%_20%,#ffffff_0%,transparent_45%),radial-gradient(circle_at_80%_80%,#d5df50_0%,transparent_38%)]" />
<div className="relative h-full w-full flex items-center justify-center px-6 py-10">
<div className="w-full max-w-xl rounded-3xl border border-white/25 bg-white/12 backdrop-blur-md shadow-2xl p-8 text-white">
<div className="flex items-center gap-3 mb-5">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-white/20 border border-white/35">
<Construction className="w-6 h-6" />
</span>
<span className="inline-flex items-center gap-2 rounded-full bg-white/15 border border-white/30 px-3 py-1 text-sm font-semibold">
<MonitorOff className="w-4 h-4" />
Uso em desktop
</span>
</div>
<h1 className="font-title text-3xl leading-tight mb-3">{title}</h1>
<p className="font-sans text-base md:text-lg text-white/95 leading-relaxed">
{message}
</p>
<div className="mt-6 inline-flex items-center rounded-full bg-black/20 border border-white/25 px-4 py-2 text-sm font-medium">
Dica: em telas maiores, esta área libera editor e interações completas.
</div>
<button
type="button"
onClick={() => navigate(homePath)}
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[var(--action-green)] text-black px-5 py-3 font-semibold shadow-lg transition-transform duration-200 hover:scale-[1.02]"
>
<House className="w-4 h-4" />
{homeLabel}
</button>
</div>
</div>
</section>
);
}
DesktopOnlyTapume.propTypes = {
title: PropTypes.string,
message: PropTypes.string,
homePath: PropTypes.string,
homeLabel: PropTypes.string,
};

View File

@@ -0,0 +1,240 @@
/**
* @fileoverview React component for Navbar.jsx
*
* @module components.Navbar
*/
import { isSession, Link } from "react-router-dom";
import { Menu, X, ChevronDown } from "lucide-react";
import { useState, useEffect } from "react";
import logo from "../assets/logo_decoda.svg";
const Navbar = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [openDropdown, setOpenDropdown] = useState(null);
const [closeTimeout, setCloseTimeout] = useState(null);
const isOffline = import.meta.env.VITE_IS_OFFLINE === "true";
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll(); // Check initial state
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleMouseEnter = (dropdown) => {
if (closeTimeout) {
clearTimeout(closeTimeout);
setCloseTimeout(null);
}
setOpenDropdown(dropdown);
};
const handleMouseLeave = () => {
const timeout = setTimeout(() => {
setOpenDropdown(null);
}, 150);
setCloseTimeout(timeout);
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 border-b border-white/20 transition-all duration-300 bg-white`}
>
<div className="flex items-center justify-between px-4 py-1 lg:px-12 lg:py-12">
{/* Logo - Esquerda */}
<div className="flex items-center space-x-4">
<Link
to="/"
className="header-logo group cursor-pointer flex items-center space-x-2 hover:bg-white/10 px-3 py-2 rounded-lg transition-all duration-200"
>
<img
src={logo}
alt="Decoda Logo"
className="h-[83px] transition-transform duration-200 group-hover:scale-110"
/>
</Link>
</div>
{/* Espaço Central vazio para empurrar o menu para a direita */}
<div className="flex-1 flex justify-center"></div>
{/* Menu Desktop - Direita */}
<div className="flex items-center space-x-2">
<div className="hidden md:flex items-center gap-6 lg:gap-8">
{/* Links diretos - Primeiros Passos */}
<Link
to="/primeiros-passos"
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
>
Primeiros Passos
</Link>
{/* Links diretos - Atividades */}
<Link
to="/atividades"
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
>
Programação
</Link>
{/* Dropdown - Laboratórios */}
<div
className="relative"
onMouseEnter={() => handleMouseEnter("laboratorios")}
onMouseLeave={handleMouseLeave}
>
<button className="flex items-center gap-1 text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4">
Laboratórios
<ChevronDown className="size-4" />
</button>
{openDropdown === "laboratorios" && (
<div className="absolute top-full left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<Link
to="/playground"
className="block px-4 py-2 text-black hover:bg-gray-100 transition-colors"
>
Laboratório de Blocos
</Link>
{(isOffline === false) && (
<Link
to="/laboratorio-python/"
className="block px-4 py-2 text-black hover:bg-gray-100 transition-colors"
>
Laboratório Python
</Link>
)}
</div>
)}
</div>
{/* Links diretos - Iniciativas */}
<Link
to="/iniciativas"
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
>
Iniciativas
</Link>
<Link
to="/sobre"
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
>
Quem somos
</Link>
<Link
to="/faq"
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
>
Perguntas frequentes
</Link>
{(isOffline === false) && (
<Link
to="/educadores"
className="text-black hover:text-black/80 font-medium transition-colors hover:underline underline-offset-4"
>
Para educadores
</Link>
)}
</div>
{/* Botão Mobile Menu */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="md:hidden p-2 rounded-lg hover:bg-white/10 transition-colors"
aria-label="Toggle menu"
>
{isMenuOpen ? (
<X className="size-10 text-black" />
) : (
<Menu className="size-10 text-black" />
)}
</button>
</div>
</div>
{/* Menu Mobile */}
{isMenuOpen && (
<div className="md:hidden border-t border-white/20 bg-white backdrop-blur-sm">
<div className="px-6 py-4 space-y-2">
{/* Links diretos Mobile */}
<Link
to="/atividades"
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Atividades
</Link>
<Link
to="/primeiros-passos"
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Primeiros Passos
</Link>
{/* Laboratórios no mobile */}
<div className="space-y-1">
<div className="px-4 py-2 text-black font-medium">
Laboratórios
</div>
<Link
to="/playground"
className="block pl-8 pr-4 py-2 text-black hover:bg-black/10 rounded-lg transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Laboratório de Blocos
</Link>
<Link
to="/laboratorio-python/"
className="block pl-8 pr-4 py-2 text-black hover:bg-black/10 rounded-lg transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Laboratório Python
</Link>
</div>
<Link
to="/iniciativas"
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Iniciativas
</Link>
<Link
to="/sobre"
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Quem somos
</Link>
<Link
to="/faq"
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Perguntas frequentes
</Link>
<Link
to="/educadores"
className="block px-4 py-2 text-black hover:bg-black/10 rounded-lg font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Para educadores
</Link>
</div>
</div>
)}
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,22 @@
/**
* @fileoverview Component to scroll to top on route change
*
* @module components.ScrollToTop
*/
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
/**
* Component that scrolls window to top whenever the route changes.
* Must be placed inside Router.
*/
export default function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview React component for ConfettiOverlay.jsx
*
* @module components.game.ConfettiOverlay
*/
import React, { useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { confetti } from "@tsparticles/confetti";
const ConfettiOverlay = ({ isActive, onComplete, // english alias
active, onFinish }) => {
const isActiveEffective = isActive ?? active;
const onCompleteEffective = onComplete ?? onFinish;
const canvasRef = useRef(null);
const timeoutRef = useRef(null);
useEffect(() => {
if (isActiveEffective && canvasRef.current) {
const canvas = canvasRef.current;
const randomInRange = (min, max) => {
return Math.random() * (max - min) + min;
};
const triggerConfettiBlast = async () => {
const defaultOptions = {
angle: randomInRange(55, 125),
spread: randomInRange(50, 70),
particleCount: randomInRange(50, 100),
origin: { y: 0.6 },
canvas: canvas,
};
await confetti(defaultOptions);
};
triggerConfettiBlast();
timeoutRef.current = setTimeout(() => {
if (onCompleteEffective) {
onCompleteEffective();
}
}, 500);
} else if (!isActive && canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [isActiveEffective, onCompleteEffective]);
useEffect(() => {
const updateCanvasSize = () => {
if (canvasRef.current) {
canvasRef.current.width = window.innerWidth;
canvasRef.current.height = window.innerHeight;
}
};
updateCanvasSize();
window.addEventListener("resize", updateCanvasSize);
return () => window.removeEventListener("resize", updateCanvasSize);
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 9999,
opacity: isActiveEffective ? 1 : 0,
transition: "opacity 0.3s ease",
}}
/>
);
};
ConfettiOverlay.propTypes = {
isActive: PropTypes.bool,
active: PropTypes.bool,
onComplete: PropTypes.func,
onFinish: PropTypes.func,
};
export default ConfettiOverlay;

View File

@@ -0,0 +1,72 @@
/**
* @fileoverview React component for ConfirmacaoModal.jsx
*
* @module components.game.ConfirmacaoModal
*/
// components/game/ConfirmacaoModal.jsx
import { X } from "lucide-react";
import PropTypes from "prop-types";
export default function ConfirmacaoModal({
isOpen,
onClose,
onConfirm,
title,
message,
}) {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white/95 backdrop-blur-sm border border-white/30 rounded-xl shadow-xl max-w-md w-full p-6 relative"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-gray-800">{title}</h3>
<button
className="w-8 h-8 rounded-lg bg-gray-200 hover:bg-gray-300 flex items-center justify-center text-gray-600 transition-colors"
onClick={onClose}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Mensagem */}
<p className="text-gray-700 mb-6">{message}</p>
{/* Ações */}
<div className="flex justify-end space-x-3">
<button
className="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
onClick={onClose}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => {
if (onConfirm) onConfirm();
onClose();
}}
>
Confirmar
</button>
</div>
</div>
</div>
);
}
ConfirmacaoModal.propTypes = {
isOpen: PropTypes.bool,
onClose: PropTypes.func,
onConfirm: PropTypes.func,
title: PropTypes.string,
message: PropTypes.string,
};

View File

@@ -0,0 +1,100 @@
/**
* @fileoverview React component for FalhaModal.jsx
*
* @module components.game.FalhaModal
*/
import React from "react";
import PropTypes from "prop-types";
import { RefreshCw } from "lucide-react";
import { ModalBase } from "./modal/ModalBase";
import { ModalHeader } from "./modal/ModalHeader";
import { CodeArea } from "./modal/CodeArea";
import { FeedbackBox } from "./modal/FeedbackBox";
const FalhaModal = ({
isOpen,
onClose,
onRetry,
customMessage,
currentPhase,
generatedCode,
}) => {
const handleRetry = () => {
onClose();
if (onRetry) {
onRetry();
}
};
const mensagemExibida =
customMessage ??
"Ops! Parece que algo não funcionou como esperado. Tente novamente!";
return (
<ModalBase isOpen={isOpen} onClose={onClose}>
<ModalHeader
title="Quase lá! Tente novamente"
subTitle={`Fase ${currentPhase}`}
variant="failure"
onClose={onClose}
/>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="p-6 flex-1 overflow-auto">
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<p className="text-red-800 font-medium">{mensagemExibida}</p>
</div>
<CodeArea
title="Seu Código Atual"
code={generatedCode}
variant="failure"
/>
<FeedbackBox title="Dicas para o Desafio" variant="failure">
<ul className="text-amber-800 text-sm space-y-1 ml-4 list-disc">
<li>Revise o enunciado.</li>
<li>Verifique se os blocos estão corretamente conectados.</li>
<li>
Certifique-se de que a lógica atende a todos os requisitos da
fase.
</li>
</ul>
</FeedbackBox>
</div>
</div>
{/* 3. Rodapé (Botões de Ação) */}
<div className="p-6 border-t border-gray-200 flex justify-between items-center bg-gray-50">
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-full font-medium transition-colors"
>
Fechar
</button>
<button
onClick={handleRetry}
className="flex items-center space-x-2 px-6 py-3 rounded-full font-medium transition-all duration-200 shadow-md bg-red-500 hover:bg-red-600 text-white"
>
<RefreshCw className="w-4 h-4" />
<span>Tentar Novamente</span>
</button>
</div>
</ModalBase>
);
};
FalhaModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onRetry: PropTypes.func,
customMessage: PropTypes.string,
currentPhase: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
generatedCode: PropTypes.string,
};
export default FalhaModal;

View File

@@ -0,0 +1,138 @@
/**
* @fileoverview React component for GameArea.jsx
*
* @module components.game.GameArea
*/
import { useEffect, useState, useRef } from "react";
import { useGameState, GAME_STATES } from "../../contexts/GameStateContext";
import { gameEventBus } from "../../utils/gameEvents";
import ConfettiOverlay from "./ConfettiOverlay";
import PropTypes from "prop-types";
export default function GameArea({
children,
blocksRemaining = null,
phaseId = null,
remainingBlocksLabel = null,
}) {
const {
executionState,
generatedCode,
finalizeWithSuccess,
finalizeWithFailure,
setFailureMessage,
} = useGameState();
const [showConfetti, setShowConfetti] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const previousPhaseId = useRef(phaseId);
const effectivePhaseId = phaseId;
const remainingBlocks = blocksRemaining;
useEffect(() => {
if (effectivePhaseId !== null && effectivePhaseId !== previousPhaseId.current) {
setIsTransitioning(true);
const timer = setTimeout(() => {
setIsTransitioning(false);
previousPhaseId.current = effectivePhaseId;
}, 600);
return () => clearTimeout(timer);
}
}, [effectivePhaseId]);
useEffect(() => {
const handleGameSuccess = () => {
finalizeWithSuccess();
setShowConfetti(true);
};
const handleGameFailure = (e) => {
const reason = e.detail?.reason;
if (reason) setFailureMessage(reason);
finalizeWithFailure();
};
gameEventBus.addEventListener("gameSuccess", handleGameSuccess);
gameEventBus.addEventListener("gameFailure", handleGameFailure);
return () => {
gameEventBus.removeEventListener("gameSuccess", handleGameSuccess);
gameEventBus.removeEventListener("gameFailure", handleGameFailure);
};
}, [finalizeWithSuccess, finalizeWithFailure]);
useEffect(() => {
switch (executionState) {
case GAME_STATES.EXECUTANDO:
if (generatedCode) {
const codigo =
typeof generatedCode === "string"
? generatedCode
: generatedCode.codigo;
const ws =
typeof generatedCode === "object" ? generatedCode.workspace : null;
gameEventBus.executeCode(codigo, ws);
}
break;
case GAME_STATES.PARADO:
gameEventBus.resetGame();
setShowConfetti(false);
break;
}
}, [executionState, generatedCode]);
const remainingLabel = remainingBlocksLabel ?? "Blocos restantes";
return (
<div
className="w-full h-full overflow-hidden relative flex items-center justify-center game-area-container bg-gray-900"
id="visualization"
>
{/* Confetti de sucesso de uma fase */}
<ConfettiOverlay isActive={showConfetti} />
{/* Overlay de transição */}
<div
className={`absolute inset-0 flex items-center justify-center bg-gray-900 transition-opacity duration-300 ease-in-out z-10 ${
isTransitioning ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
style={{ borderRadius: "5px" }}
>
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
</div>
{/* Indicador de blocos restantes */}
{remainingBlocks !== null && !isNaN(remainingBlocks) && (
<div id="capacityBubble">
<div id="capacity" style={{ display: "block" }}>
{remainingLabel}: {" "}
<span className="capacityNumber">{remainingBlocks}</span>
</div>
</div>
)}
<div
className="flex items-center justify-center w-full h-full phaser-container"
style={{
backgroundColor: "#242527",
}}
>
{children}
</div>
</div>
);
}
GameArea.propTypes = {
children: PropTypes.node,
blocksRemaining: PropTypes.number,
phaseId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
remainingBlocksLabel: PropTypes.string,
};

View File

@@ -0,0 +1,213 @@
/**
* @fileoverview React component for GameBase.jsx
*
* @module components.game.GameBase
*/
import React from "react";
import PropTypes from "prop-types";
import { Panel, PanelGroup } from "react-resizable-panels";
import GameNavBar from "./GameNavBar";
import GameFaseInfo from "./GameFaseInfo";
import GameArea from "./GameArea";
import GameFooter from "./GameFooter";
import SeletorDeFases from "./SeletorDeFases";
import SucessoModal from "./SucessoModal";
import FalhaModal from "./FalhaModal";
import ResizeHandle from "./ResizeHandle";
import { useIsMobile } from "../../hooks/useIsMobile";
import { useGameState } from "../../contexts/GameStateContext";
import { EditorProvider } from "../../contexts/EditorContext";
import { usePhaser } from "../../hooks/usePhaser";
import { useGameModals } from "../../hooks/useGameModals";
function GameBaseContent({
gameFactory,
gameConfig,
children,
onHelpClick,
customFailureHandler,
}) {
const isMobile = useIsMobile();
const {
currentPhase,
setCurrentPhase,
resetProgress,
executionState,
generatedCode,
failureMessage,
restart,
setOnWorkspaceChange,
} = useGameState();
const phaseConfig = gameConfig.fases[currentPhase - 1];
const usaModalFalha = !!customFailureHandler;
const { gameContainerRef } = usePhaser({
gameFactory,
phaseConfig,
currentPhase,
customFailureHandler,
gameConfig,
});
const {
modalFasesAberto,
setModalFasesAberto,
modalSucessoAberto,
modalFalhaAberto,
blocksRemainingCount,
handleProximaFase,
handleFecharModalSucesso,
handleFecharModalFalha,
handleTentarNovamente,
} = useGameModals({
executionState,
currentPhase,
phaseConfig,
gameConfig,
setCurrentPhase,
restart,
setOnWorkspaceChange,
usaModalFalha,
});
const handleResetProgresso = () => {
resetProgress();
window.dispatchEvent(
new CustomEvent("resetBlocklyWorkspace", {
detail: { gameId: gameConfig.gameId },
}),
);
};
const codeToShow = React.useMemo(() => {
if (!generatedCode) return "Nenhum código gerado";
let codigo = "";
if (typeof generatedCode === "string") {
codigo = generatedCode;
} else if (typeof generatedCode === "object" && generatedCode.codigo) {
codigo = generatedCode.codigo;
} else {
return "Código não disponível";
}
const codigoLimpo = codigo
.split("\n")
.filter((linha) => !linha.trim().startsWith("highlightBlock("))
.join("\n")
.trim();
return codigoLimpo || codigo;
}, [generatedCode]);
return (
<div className="game-base-page flex flex-col h-screen w-screen">
<GameNavBar
title={`${gameConfig.gameName}`}
type={`${gameConfig.type}`}
thumbnail={gameConfig.thumbnail}
icon={gameConfig.icon}
/>
<GameFaseInfo phaseData={phaseConfig} phaseNumber={currentPhase} />
<div className="flex-1 min-h-0 flex flex-col">
<PanelGroup
direction={isMobile ? "vertical" : "horizontal"}
className="h-full w-full"
>
<Panel defaultSize={isMobile ? 48 : 48} minSize={isMobile ? 10 : 10}>
<EditorProvider gameConfig={gameConfig} currentPhase={currentPhase}>
{children}
</EditorProvider>
</Panel>
<ResizeHandle direction={isMobile ? "vertical" : "horizontal"} />
<Panel defaultSize={isMobile ? 49 : 49} minSize={isMobile ? 10 : 10}>
<GameArea blocksRemaining={blocksRemainingCount} phaseId={currentPhase}>
<div ref={gameContainerRef} className="w-full h-full" />
</GameArea>
</Panel>
</PanelGroup>
</div>
<GameFooter
gameConfig={gameConfig}
currentPhase={currentPhase}
onOpenPhaseSelector={() => setModalFasesAberto(true)}
onHelpClick={onHelpClick}
/>
<SeletorDeFases
isVisible={modalFasesAberto}
onClose={() => setModalFasesAberto(false)}
currentPhase={currentPhase}
gameConfig={gameConfig}
onChangePhase={(fase) => {
setCurrentPhase(fase);
setModalFasesAberto(false);
}}
onResetProgress={handleResetProgresso}
/>
<SucessoModal
isOpen={modalSucessoAberto}
onClose={handleFecharModalSucesso}
onNextPhase={handleProximaFase}
generatedCode={codeToShow}
currentPhase={currentPhase}
totalPhases={gameConfig.fases.length}
canGoNext={currentPhase < gameConfig.fases.length}
/>
<FalhaModal
isOpen={modalFalhaAberto}
onClose={handleFecharModalFalha}
onRetry={handleTentarNovamente}
customMessage={failureMessage}
currentPhase={currentPhase}
generatedCode={codeToShow}
/>
</div>
);
}
export default function GameBase({
gameFactory,
gameConfig,
children,
onHelpClick,
customFailureHandler,
}) {
return (
<GameBaseContent
gameFactory={gameFactory}
gameConfig={gameConfig}
onHelpClick={onHelpClick}
customFailureHandler={customFailureHandler}
>
{children}
</GameBaseContent>
);
}
GameBase.propTypes = {
gameFactory: PropTypes.func.isRequired,
gameConfig: PropTypes.shape({
gameId: PropTypes.string.isRequired,
gameName: PropTypes.string.isRequired,
fases: PropTypes.array.isRequired,
type: PropTypes.string,
thumbnail: PropTypes.string,
icon: PropTypes.string,
}).isRequired,
children: PropTypes.node.isRequired,
onHelpClick: PropTypes.func,
customFailureHandler: PropTypes.func,
helpHandler: PropTypes.func,
failureHandler: PropTypes.func,
onHelp: PropTypes.func,
title: PropTypes.string,
};

View File

@@ -0,0 +1,147 @@
/**
* @fileoverview React component for GameEditor.jsx
*
* @module components.game.GameEditor
*/
import { Play, Loader, RotateCcw, CircleAlert } from "lucide-react";
import PropTypes from "prop-types";
import { useGameState, GAME_STATES } from "../../contexts/GameStateContext";
import { useRef, useEffect } from "react";
export default function GameEditor(props) {
const { children } = props;
const runText = props.runText ?? props.textoExecutar ?? props.runLabel ?? "Executar";
const restartText = props.restartText ?? props.textoReiniciar ?? props.restartLabel ?? "Reiniciar";
// useRef é síncrono: leitura/escrita imediata, sem batch do React
const isProcessingRef = useRef(false);
const processingTimeoutRef = useRef(null);
const {
executionState,
execute,
restart,
stop,
currentBlockCount,
editorType,
} = useGameState();
useEffect(() => {
return () => {
if (processingTimeoutRef.current) {
clearTimeout(processingTimeoutRef.current);
}
};
}, []);
const isExecuting = executionState === GAME_STATES.EXECUTANDO;
const needsRestart =
executionState === GAME_STATES.SUCESSO ||
executionState === GAME_STATES.FALHA;
const noBlocks = currentBlockCount === 0;
const handleClick = () => {
// Leitura síncrona — funciona mesmo com cliques ultra-rápidos
if (isProcessingRef.current) {
return;
}
if (noBlocks && !isExecuting && !needsRestart) {
return;
}
// Escrita síncrona — bloqueia imediatamente
isProcessingRef.current = true;
if (processingTimeoutRef.current) {
clearTimeout(processingTimeoutRef.current);
}
processingTimeoutRef.current = setTimeout(() => {
isProcessingRef.current = false;
}, 200);
if (isExecuting) {
stop();
return;
}
if (needsRestart) {
restart();
} else {
execute();
}
};
const setStyle = () => {
const style =
"game-controls-custom flex items-center space-x-2 px-6 py-3 rounded-full font-medium transition-all duration-200 shadow-md";
if (noBlocks) {
return `${style} bg-gray-300 text-black cursor-not-allowed`;
}
if (isExecuting) {
return `${style} bg-[var(--action-green)] hover:bg-[rgb(235_119_63/1)]`;
}
if (needsRestart) {
return `${style} bg-[rgb(254_0_2/1)] hover:bg-[rgb(230_0_0/1)] text-white`;
}
return `${style} bg-green-100 hover:bg-green-200 text-black`;
};
const getEmptyStateText = () => {
return editorType === "code"
? "Adicione código para execute"
: "Adicione blocos para execute";
};
return (
<div className="w-full h-full flex flex-col">
<div className="flex-1 min-h-0">{children}</div>
<div className="game-editor-controls py-1 flex flex-col items-center space-y-2">
<button
onClick={handleClick}
className={setStyle()}
disabled={noBlocks && !isExecuting && !needsRestart}
data-tour={needsRestart ? "reset-button" : "run-button"}
>
{isExecuting ? (
<>
<Loader className="w-4 h-4 animate-spin" />
<span>Executando, clique para interromper...</span>
</>
) : needsRestart ? (
<>
<RotateCcw className="w-4 h-4" />
<span>{restartText}</span>
</>
) : noBlocks ? (
<>
<CircleAlert className="w-4 h-4 opacity-50" />
<span>{getEmptyStateText()}</span>
</>
) : (
<>
<Play className="w-4 h-4" />
<span>{runText}</span>
</>
)}
</button>
</div>
</div>
);
}
GameEditor.propTypes = {
runText: PropTypes.string,
runLabel: PropTypes.string,
textoExecutar: PropTypes.string,
restartText: PropTypes.string,
restartLabel: PropTypes.string,
textoReiniciar: PropTypes.string,
children: PropTypes.node,
};

View File

@@ -0,0 +1,85 @@
import React from "react";
import PropTypes from "prop-types";
function obterDificuldade(dadosFase) {
// Usa a dificuldade da fase, se existir, senão calcula pelo número
if (dadosFase?.dificuldade) {
switch (dadosFase.dificuldade) {
case "Fácil":
return { nivel: "Fácil", cor: "bg-green-500", emoji: "😊" };
case "Médio":
return { nivel: "Médio", cor: "bg-yellow-500", emoji: "🤔" };
case "Difícil":
return { nivel: "Difícil", cor: "bg-orange-500", emoji: "😤" };
case "Extremo":
return { nivel: "Extremo", cor: "bg-red-500", emoji: "🔥" };
default:
return null;
}
}
return null;
}
// English alias for migration
const getDifficulty = (phaseData) => obterDificuldade(phaseData);
function GameFaseInfo({ phaseData = {}, phaseNumber }) {
const dificuldade = getDifficulty(phaseData);
return (
<div className="game-fase-info p-1 lg:px-3 lg:py-2">
{phaseData && phaseData.nome ? (
<div className="flex items-center justify-between">
{/* Número da fase */}
<div className="flex-shrink-0 w-8 h-8 lg:w-12 lg:h-12 bg-green-100 rounded-full flex items-center justify-center text-black text-sm lg:text-lg font-bold shadow-lg">
{phaseNumber}
</div>
{/* Título/Subtítulo */}
<div className="flex-1 min-w-0 px-3 lg:px-5">
<h3 className="text-base lg:text-2xl font-semibold text-gray-800 truncate">
{phaseData.nome}
</h3>
{phaseData.descricao && (
<p className="text-base lg:text-lg text-gray-600 leading-tight whitespace-pre-wrap mt-1 pr-12 lg:pr-0">
{phaseData.descricao}
</p>
)}
</div>
{/* Dificuldade */}
<div className="flex-shrink-0">
{dificuldade && (
<div
className={`flex items-center space-x-1 text-xs lg:text-sm text-white px-2 py-1 lg:px-3 lg:py-1.5 rounded-full font-medium ${dificuldade.cor}`}
>
<span className="lg:text-base">{dificuldade.emoji}</span>
<span>{dificuldade.nivel}</span>
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-between text-gray-500">
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-green-100 rounded-full flex items-center justify-center text-black text-sm lg:text-lg font-title font-bold">
?
</div>
<div className="flex-1 px-3 lg:px-5">
<p className="text-xs lg:text-sm">
Selecione uma fase para começar
</p>
</div>
<div className="flex-shrink-0"></div>
</div>
)}
</div>
);
}
export default GameFaseInfo;
export { getDifficulty };
GameFaseInfo.propTypes = {
};

View File

@@ -0,0 +1,73 @@
/**
* @fileoverview React component for GameFooter.jsx
*
* @module components.game.GameFooter
*/
import React from "react";
import PropTypes from "prop-types";
export default function GameFooter({
gameConfig,
currentPhase,
onHelpClick,
onOpenPhaseSelector,
}) {
const totalPhases = gameConfig.fases.length;
const displayPhase = currentPhase ?? currentPhase;
const ajuda = () => {
if (onHelpClick) {
onHelpClick();
return;
}
alert("Recurso de ajuda será implementado em breve!");
};
return (
<div className="bg-gray-900">
<div className="flex items-center justify-between px-6 py-3">
{/* Lado esquerdo - Botão de Ajuda (desativado temporariamente, tour será reimplementado) */}
<div className="flex items-center invisible">
<button
onClick={ajuda}
data-tour="help-button"
title="Ajuda"
className="bg-green-100 text-black font-medium py-2 px-6 lg:py-3 lg:px-9 text-sm lg:text-base rounded-full transition-all duration-200 hover:bg-green-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-green-300 focus:ring-opacity-50"
>
Ajuda
</button>
</div>
{/* Centro - Indicador de Fase Atual/Total */}
<div className="flex items-center space-x-4">
<div className="phase-indicator">
<span className="text-white font-medium text-sm lg:text-xl">
<span className="phase-current">{displayPhase}</span>
<span className="phase-separator">/</span>
<span className="phase-total">{totalPhases}</span>
</span>
</div>
</div>
{/* Lado direito - Botão do Seletor de Fases */}
<div className="flex items-center">
<button
onClick={onOpenPhaseSelector}
data-tour="phase-selector"
title="Selecionar Fase"
className="bg-green-100 text-black font-medium py-2 px-6 lg:py-3 lg:px-9 lg:text-lg rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-300 focus:ring-opacity-50 hover:bg-green-200 hover:scale-105 hover:shadow-lg"
>
Fases
</button>
</div>
</div>
</div>
);
}
GameFooter.propTypes = {
gameConfig: PropTypes.object.isRequired,
currentPhase: PropTypes.number,
onHelpClick: PropTypes.func,
onOpenPhaseSelector: PropTypes.func,
};

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview React component for GameNavBar.jsx
*
* @module components.game.GameNavBar
*/
import { useState } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import logo from "../../assets/logo_decoda.svg";
import { Code, Puzzle } from "lucide-react";
import { ArrowLeft } from "lucide-react";
export default function GameNavBar({ title, label, type = "blocks", thumbnail, icon }) {
const titleToShow = title ?? label;
const navigate = useNavigate();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const renderIcon = (size = "w-6 h-6 lg:w-8 lg:h-8") => {
if (type === "code") {
return <Code className={`text-white ${size}`} />;
} else {
return <Puzzle className={`text-white ${size}`} />;
}
};
return (
<>
{/* Navbar normal - oculto em telas pequenas */}
<nav className="bg-white sticky top-0 z-50 border-b border-white/20 shadow-lg hidden sm:block">
<div className="flex items-center justify-between px-5 py-2 lg:py-3 lg:px-10">
<div className="flex items-center space-x-4">
<div
onClick={() => navigate("/")}
title="Ir para Home"
className="header-logo group cursor-pointer flex items-center space-x-2"
>
<img
src={logo}
alt="Decoda Logo"
className="h-[30px] transition-transform duration-200 group-hover:scale-110"
/>
</div>
</div>
<div className="flex-1 flex justify-center"></div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-3">
{renderIcon("w-8 h-8")}
<h5 className="text-brand-500 font-title font-semibold text-3xl">{titleToShow}</h5>
{thumbnail && (
<img
src={thumbnail}
alt={titleToShow}
className="h-8 w-8 object-cover rounded-lg"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
)}
</div>
</div>
</div>
</nav>
{/* Botão menu flutuante - só aparece em telas pequenas */}
<button
className="fixed top-4 right-4 z-50 sm:hidden bg-white/80 rounded-full p-2 shadow-lg"
aria-label="Abrir menu"
onClick={() => setIsMenuOpen(true)}
>
<svg width="32" height="32" fill="none" viewBox="0 0 24 24">
<rect x="4" y="7" width="16" height="2" rx="1" fill="#a21caf" />
<rect x="4" y="11" width="16" height="2" rx="1" fill="#a21caf" />
<rect x="4" y="15" width="16" height="2" rx="1" fill="#a21caf" />
</svg>
</button>
{/* Overlay do menu mobile */}
{isMenuOpen && (
<div className="fixed inset-0 z-50 bg-black bg-opacity-70 flex flex-col items-end ">
<div className="bg-gray-100 h-full p-5 w-[60%] flex flex-col justify-between">
<div>
<div className="w-full flex justify-end">
<button
onClick={() => setIsMenuOpen(false)}
className="text-brand-500 text-3xl"
aria-label="Fechar menu"
>
&times;
</button>
</div>
<div className="flex items-center space-x-3">
{type === "code" && <Code className="text-brand-500 size-6" />}
{type === "blocks" && (
<Puzzle className="text-brand-500 size-5" />
)}
<h5 className="text-bold font-title font-semibold text-2xl">{titleToShow}</h5>
{thumbnail && (
<img
src={thumbnail}
alt={titleToShow}
className="h-6 w-6 object-cover rounded-lg"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
)}
</div>
<button
onClick={() => {
setIsMenuOpen(false);
navigate("/");
}}
className="flex items-center gap-5 mt-10"
>
<ArrowLeft className="text-brand-500" />
<span className="text-black font-bold text-base">Voltar</span>
</button>
</div>
<button
onClick={() => {
setIsMenuOpen(false);
navigate("/");
}}
>
<img
src={logo}
alt="Decoda"
className="h-[83px]"
onError={(e) => {
e.target.style.display = "none";
e.target.nextSibling.style.display = "inline";
}}
/>
</button>
</div>
</div>
)}
</>
);
}
GameNavBar.propTypes = {
title: PropTypes.string,
label: PropTypes.string,
type: PropTypes.string,
thumbnail: PropTypes.string,
icon: PropTypes.string,
};

View File

@@ -0,0 +1,61 @@
/**
* @fileoverview React component for ResizeHandle.jsx
*
* @module components.game.ResizeHandle
*/
import { PanelResizeHandle } from "react-resizable-panels";
import PropTypes from "prop-types";
export default function ResizeHandle({
direction = "horizontal",
// English alias
orientation,
theme = "light",
disabled = false,
}) {
const effectiveDirection = direction ?? orientation ?? "horizontal";
const horizontal = effectiveDirection === "horizontal";
const dark = theme === "dark";
return (
<PanelResizeHandle
disabled={disabled}
className={`
group
${horizontal ? "w-3" : "h-3"}
${disabled ? "cursor-default" : horizontal ? "cursor-col-resize" : "cursor-row-resize"}
flex items-center justify-center
${dark ? "bg-gray-700 hover:bg-gray-600" : "bg-gray-200 hover:bg-gray-300"}
transition-colors
`}
>
<div
className={`
flex ${horizontal ? "flex-col space-y-1" : "flex-row space-x-1"}
items-center justify-center
`}
>
<span
className={`block w-1 h-1 rounded-full ${dark ? "bg-gray-400 group-hover:bg-gray-300" : "bg-gray-500 group-hover:bg-gray-700"}`}
/>
<span
className={`block w-1 h-1 rounded-full ${dark ? "bg-gray-400 group-hover:bg-gray-300" : "bg-gray-500 group-hover:bg-gray-700"}`}
/>
<span
className={`block w-1 h-1 rounded-full ${dark ? "bg-gray-400 group-hover:bg-gray-300" : "bg-gray-500 group-hover:bg-gray-700"}`}
/>
</div>
</PanelResizeHandle>
);
}
ResizeHandle.propTypes = {
direction: PropTypes.string,
orientation: PropTypes.string,
theme: PropTypes.string,
disabled: PropTypes.bool,
};

View File

@@ -0,0 +1,178 @@
/**
* @fileoverview React component for SeletorDeFases.jsx
*
* @module components.game.SeletorDeFases
*/
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import ConfirmacaoModal from "./ConfirmacaoModal";
import { Lock, CheckCircle, Star, X } from "lucide-react";
import { getUnlockedPhases } from "../../utils/phaseUtils";
export default function SeletorDeFases({
isVisible,
onClose,
gameConfig,
currentPhase,
onChangePhase,
onResetProgress,
}) {
const gameId = gameConfig.gameId;
const totalPhases = gameConfig.fases.length;
const storageKey = `${gameId}-completed-phases`;
const [completedPhases, setCompletedPhases] = useState([]);
const [showResetConfirmation, setShowResetConfirmation] = useState(false);
useEffect(() => {
if (!isVisible) return;
const salvo = localStorage.getItem(storageKey);
if (salvo) {
try {
const fases = JSON.parse(salvo);
setCompletedPhases(fases);
} catch {
setCompletedPhases([]);
}
} else {
setCompletedPhases([]);
}
}, [isVisible, storageKey]);
const unlockedPhases = getUnlockedPhases(completedPhases, totalPhases);
const selectPhase = (number) => {
if (!unlockedPhases.includes(number)) return;
if (onChangePhase) onChangePhase(number);
onClose();
};
if (!isVisible) return null;
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white/95 backdrop-blur-sm border border-white/30 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-white/20">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center text-black">
<Star className="w-5 h-5" />
</div>
<h3 className="text-xl font-title font-bold text-gray-800">
Selecionar Fase - {gameConfig.gameName || "Jogo"}
</h3>
</div>
<button
onClick={onClose}
className="w-8 h-8 cancel-icon bg-gray-200 hover:bg-gray-300 flex items-center justify-center text-gray-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: totalPhases }, (_, i) => {
const phaseNumber = i + 1;
const isUnlocked = unlockedPhases.includes(phaseNumber);
const wasCompleted = completedPhases.includes(phaseNumber);
const isCurrent = currentPhase === phaseNumber;
const phaseData = gameConfig.fases[i];
return (
<div
key={phaseNumber}
className={`bg-white/80 backdrop-blur-sm border border-white/30 rounded-xl shadow-lg p-4 cursor-pointer transition-all duration-200 border-2 ${
isCurrent
? "border-red-500 ring-2 ring-red-200"
: isUnlocked
? "border-transparent hover:border-red-300"
: "border-gray-200 opacity-60 cursor-not-allowed"
}`}
onClick={() => isUnlocked && selectPhase(phaseNumber)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-title font-bold ${
wasCompleted
? "bg-green-100 text-green-600"
: !isUnlocked
? "bg-gray-100 text-gray-400"
: "bg-red-100 text-red-600"
}`}
>
{wasCompleted ? (
<CheckCircle className="w-4 h-4" />
) : !isUnlocked ? (
<Lock className="w-4 h-4" />
) : (
phaseNumber
)}
</div>
</div>
{isCurrent && (
<span className="text-xs bg-red-600 text-white px-2 py-1 rounded-full font-medium">
Atual
</span>
)}
</div>
<div className="space-y-2">
<h4 className="font-title font-semibold text-gray-800 text-sm">
Fase {phaseNumber}
</h4>
<h5 className="font-medium text-gray-700 text-sm">
{phaseData.nome}
</h5>
<p className="text-xs text-gray-600 line-clamp-2">
{phaseData.descricao}
</p>
<span className="inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700">
{phaseData.dificuldade}
</span>
</div>
</div>
);
})}
</div>
</div>
<div className="p-4 border-t border-white/20 flex justify-center">
<button
className="bg-green-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-green-200"
onClick={() => setShowResetConfirmation(true)}
>
Resetar TODO o progresso do jogo
</button>
</div>
<ConfirmacaoModal
isOpen={showResetConfirmation}
onClose={() => setShowResetConfirmation(false)}
onConfirm={() => {
if (onResetProgress) onResetProgress();
setShowResetConfirmation(false);
onClose();
}}
title="Resetar progresso"
message="Tem certeza que deseja apagar TODO o progresso e blocos salvos deste jogo? Esta ação não pode ser desfeita."
/>
</div>
</div>
);
}
SeletorDeFases.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func,
gameConfig: PropTypes.object.isRequired,
onChangePhase: PropTypes.func,
onResetProgress: PropTypes.func,
};

View File

@@ -0,0 +1,88 @@
/**
* @fileoverview React component for SucessoModal.jsx
*
* @module components.game.SucessoModal
*/
import React from "react";
import PropTypes from "prop-types";
import { ChevronRight } from "lucide-react";
import { ModalBase } from "./modal/ModalBase";
import { ModalHeader } from "./modal/ModalHeader";
import { CodeArea } from "./modal/CodeArea";
import { FeedbackBox } from "./modal/FeedbackBox";
const SucessoModal = ({
isOpen,
onClose,
onNextPhase,
generatedCode,
canGoNext,
}) => {
const handleNextPhase = () => {
onClose();
if (onNextPhase) {
onNextPhase();
}
};
return (
<ModalBase isOpen={isOpen} onClose={onClose}>
<ModalHeader
title="Parabéns!"
subTitle="Veja o código que foi gerado"
variant="success"
onClose={onClose}
/>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="p-6 flex-1 overflow-auto">
<CodeArea
code={typeof generatedCode === "string" ? generatedCode : generatedCode?.codigo}
variant="success"
/>
<FeedbackBox title="O que aconteceu?" variant="success">
<p>
Os blocos que você conectou foram convertidos em código JavaScript
real. Este é o mesmo tipo de código que os programadores usam para
criar aplicações!
</p>
</FeedbackBox>
</div>
</div>
{/* 3. Rodapé (Botões de Ação) */}
<div className="p-6 border-t border-gray-200 flex justify-between items-center bg-gray-50">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-full font-medium transition-colors"
>
Fechar
</button>
{canGoNext && (
<button
onClick={handleNextPhase}
className="flex items-center space-x-2 px-6 py-3 rounded-full font-medium transition-all duration-200 shadow-md bg-green-100 hover:bg-green-200 text-black"
>
<span>Próxima Fase</span>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</ModalBase>
);
};
SucessoModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onNextPhase: PropTypes.func,
generatedCode: PropTypes.any,
canGoNext: PropTypes.bool.isRequired,
};
export default SucessoModal;

View File

@@ -0,0 +1,332 @@
/**
* @fileoverview React component for BlocklyEditor.jsx
*
* @module components.game.editors.BlocklyEditor
*/
import React, {
useEffect,
useRef,
forwardRef,
useImperativeHandle,
useState,
useCallback,
useMemo,
} from "react";
import PropTypes from "prop-types";
import * as Blockly from "blockly/core";
import Theme from "@blockly/theme-modern";
import { javascriptGenerator } from "blockly/javascript";
import { useGameState } from "../../../contexts/GameStateContext";
import { validateBlocklyWorkspace } from "@/blockly/validation";
import { useEditor } from "../../../contexts/EditorContext";
import {
loadWorkspace,
createDebouncedSave,
} from "../../../services/blockstorage";
import { getCategoryIcon } from "./toolboxIcons";
import { Hammer } from "lucide-react";
import "./custom_category";
import "./BlocklyEditor.mobile.css";
const LoadingSpinner = () => (
<div className="blockly-loading-spinner">
<p className="blockly-loading-text">Carregando Editor...</p>
</div>
);
const BlocklyEditor = forwardRef(function BlocklyEditor(
{ toolboxGenerator, debugSolutions = null, starterBlocks = null, starter = null },
ref,
) {
const starterBlocksProp = starterBlocks ?? starter;
const { registerExecutionFunction, onWorkspaceChange } = useGameState();
const { gameConfig: editorGameConfig, currentPhase, editorData, updateEditorData, gameNameKey } =
useEditor();
const hasSolution = debugSolutions && debugSolutions[currentPhase];
const handleLoadSolution = () => {
if (!workspaceRef.current) {
alert("Editor não está pronto");
return;
}
if (window.confirm("Deseja carregar a solução desta fase?")) {
try {
const solution = debugSolutions[currentPhase];
workspaceRef.current.clear();
Blockly.serialization.workspaces.load(solution, workspaceRef.current);
} catch (error) {
alert("Erro ao carregar a solução");
}
}
};
useEffect(() => {
if (!editorGameConfig || !toolboxGenerator) return;
const phases = editorGameConfig.fases ?? editorGameConfig.phases; // support English alias
const phaseConfig = phases?.[currentPhase - 1];
const toolbox = toolboxGenerator(phaseConfig?.allowedBlocks);
const max = phaseConfig?.maxBlocks;
updateEditorData({
toolboxJson: toolbox,
maxBlocks: max,
});
}, [editorGameConfig, currentPhase, toolboxGenerator, updateEditorData]);
const { toolboxJson, maxBlocks } = editorData;
const blocklyDiv = useRef(null);
const workspaceRef = useRef(null);
const [initialJson, setInitialJson] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
const isInitializedRef = useRef(false);
const [currentBlockCount, setCurrentBlockCount] = useState(0);
const debouncedSave = useMemo(() => createDebouncedSave(1000), []);
const stableToolboxJson = useMemo(() => toolboxJson, [toolboxJson]);
const updateCategoriesState = useCallback((limitReached) => {
if (!workspaceRef.current) return;
const toolbox = workspaceRef.current.getToolbox();
if (!toolbox) return;
const categories = toolbox.getToolboxItems();
categories.forEach((category) => {
category.setDisabled(limitReached);
});
}, []);
const workspaceChange = useCallback(() => {
if (!workspaceRef.current) return;
const blockCount = workspaceRef.current.getAllBlocks().length;
setCurrentBlockCount(blockCount);
const limitReached =
maxBlocks !== undefined &&
maxBlocks !== Infinity &&
maxBlocks <= blockCount;
updateCategoriesState(limitReached);
if (onWorkspaceChange) {
onWorkspaceChange(blockCount);
}
}, [onWorkspaceChange, maxBlocks, updateCategoriesState]);
useEffect(() => {
updateCategoriesState();
}, [updateCategoriesState]);
useEffect(() => {
const generateAndValidateCode = () => {
if (!workspaceRef.current) {
return { codigo: null, workspace: null };
}
const validation = validateBlocklyWorkspace(workspaceRef.current, {
allowMultipleTopBlocks: editorGameConfig?.allowMultipleTopBlocks ?? false,
preferredStartBlocks: ["start", "when_run", "main"],
});
if (!validation.isValid) {
return { codigo: null, workspace: null };
}
const codigo = javascriptGenerator.workspaceToCode(workspaceRef.current);
return { codigo, workspace: workspaceRef.current };
};
registerExecutionFunction(generateAndValidateCode);
return () => {
registerExecutionFunction(null);
};
}, [registerExecutionFunction]);
useEffect(() => {
setIsLoading(true);
const loadedData = loadWorkspace(gameNameKey);
setInitialJson(loadedData);
setIsLoading(false);
}, [gameNameKey]);
useImperativeHandle(ref, () => workspaceRef.current, []);
useEffect(() => {
if (
isLoading ||
isInitializedRef.current ||
!blocklyDiv.current ||
!stableToolboxJson
) {
return;
}
isInitializedRef.current = true;
const toolboxWithIcons = {
...stableToolboxJson,
contents: stableToolboxJson.contents.map((cat) => ({
...cat,
"css-icon": getCategoryIcon(cat.name),
})),
};
workspaceRef.current = Blockly.inject(blocklyDiv.current, {
toolbox: toolboxWithIcons,
trashcan: true,
scrollbars: true,
renderer: "zelos",
theme: Theme,
grid: { spacing: 25, length: 3, colour: "#ccc", snap: true },
zoom: { controls: false, wheel: true, startScale: 0.7 },
});
// Criar variáveis pré-definidas para o jogo Cripto
if (editorGameConfig?.gameId === "cripto") {
["entrada", "saida", "pos"].forEach((varName) => {
const variableMap = workspaceRef.current.getVariableMap();
if (!variableMap.getVariable(varName)) {
workspaceRef.current.createVariable(varName);
}
});
}
// Carregar workspace: prioridade = localStorage > starterBlocks > vazio
if (initialJson) {
try {
Blockly.serialization.workspaces.load(
initialJson,
workspaceRef.current,
);
} catch (error) {
console.error(
"Falha ao carregar workspace do localStorage, limpando.",
error,
);
workspaceRef.current.clear();
}
} else if (starterBlocksProp && starterBlocksProp[currentPhase]) {
// Se não há nada salvo, carregar blocos iniciais da fase
try {
Blockly.serialization.workspaces.load(
starterBlocksProp[currentPhase],
workspaceRef.current,
);
} catch (error) {
console.error("Falha ao carregar blocos iniciais.", error);
}
}
workspaceChange();
const listener = (event) => {
if (!workspaceRef.current || event.isUiEvent) {
return;
}
if (event.type === Blockly.Events.BLOCK_CREATE) {
const currentCount = workspaceRef.current.getAllBlocks().length;
if (
maxBlocks !== undefined &&
maxBlocks !== Infinity &&
currentCount > maxBlocks
) {
const blockToRemove = workspaceRef.current.getBlockById(
event.blockId,
);
if (blockToRemove) {
console.warn(
`Limite de blocos atingido (${maxBlocks}). Removendo bloco extra: ${event.blockId}`,
);
Blockly.Events.disable();
blockToRemove.dispose(false);
Blockly.Events.enable();
return;
}
}
}
workspaceChange();
const currentState = Blockly.serialization.workspaces.save(
workspaceRef.current,
);
debouncedSave(gameNameKey, currentState);
};
workspaceRef.current.addChangeListener(listener);
const observer = new ResizeObserver(() => {
if (workspaceRef.current) {
Blockly.svgResize(workspaceRef.current);
}
});
observer.observe(blocklyDiv.current);
setTimeout(() => {
updateCategoriesState();
const bg = document.querySelector(".blocklyToolboxBackground");
if (bg) {
bg.setAttribute("fill", "none");
bg.setAttribute("stroke", "none");
}
}, 100);
return () => {
debouncedSave.cancel();
if (workspaceRef.current) {
workspaceRef.current.removeChangeListener(listener);
try {
workspaceRef.current.dispose();
} catch (error) {
console.warn("Erro ao fazer dispose do workspace:", error);
}
workspaceRef.current = null;
}
observer.disconnect();
isInitializedRef.current = false;
};
}, [
isLoading,
initialJson,
gameNameKey,
stableToolboxJson,
debouncedSave,
workspaceChange,
maxBlocks,
updateCategoriesState,
editorGameConfig,
currentPhase,
]);
if (isLoading || !toolboxJson) {
return <LoadingSpinner />;
}
return (
<div className="relative w-full h-full">
{hasSolution && (
<button
onClick={handleLoadSolution}
className="blockly-debug-solution-btn"
title="Carregar solução (Debug)"
>
<Hammer className="w-5 h-5 text-yellow-500" />
</button>
)}
<div ref={blocklyDiv} className="w-full h-full" />
</div>
);
});
export default React.memo(BlocklyEditor);
BlocklyEditor.propTypes = {
toolboxGenerator: PropTypes.func.isRequired,
debugSolutions: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
starterBlocks: PropTypes.object,
starter: PropTypes.object,
};

View File

@@ -0,0 +1,237 @@
/* Loading Spinner Styles */
.blockly-loading-spinner {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
}
.blockly-loading-text {
font-size: 18px;
color: #555;
}
/* Estilos para destaque de blocos durante execução */
.blocklySelected > .blocklyPath {
stroke: #667eea !important;
stroke-width: 1px !important;
filter: drop-shadow(0 0 10px rgba(53, 2, 92, 1.7)) !important;
}
.blocklySelected {
z-index: 999 !important;
}
/* ===== BLOCKLY CUSTOMIZATIONS ===== */
.blocklyScrollbarVertical,
.blocklyScrollbarHorizontal,
.blocklyToolboxDiv .blocklyScrollbarVertical,
.blocklyToolboxDiv .blocklyScrollbarHorizontal,
.blocklyFlyout .blocklyScrollbarVertical,
.blocklyFlyout .blocklyScrollbarHorizontal,
.blocklyWorkspace .blocklyScrollbarVertical,
.blocklyWorkspace .blocklyScrollbarHorizontal,
.blocklyDiv .blocklyScrollbarVertical,
.blocklyDiv .blocklyScrollbarHorizontal,
.blocklyDiv * .blocklyScrollbarVertical,
.blocklyDiv * .blocklyScrollbarHorizontal,
.blocklyScrollbarHandle,
.blocklyScrollbarKnob,
.blocklyScrollbarBackground,
div[class*="blockly"] .blocklyScrollbarVertical,
div[class*="blockly"] .blocklyScrollbarHorizontal,
svg[class*="blockly"] .blocklyScrollbarVertical,
svg[class*="blockly"] .blocklyScrollbarHorizontal {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
}
.blocklyWorkspace,
.blocklyWorkspace svg,
.blocklyToolboxDiv,
.blocklyToolboxDiv *,
.blocklyFlyout,
.blocklyFlyout *,
.blocklyDiv,
.blocklyDiv *,
.blocklyMainBackground,
.blocklyTreeRoot {
overflow: hidden !important;
}
.blocklyDiv {
background-color: #f8f9fa;
min-height: 200px;
}
.blocklyDiv .blocklyWorkspace {
background-color: #f8f9fa !important;
}
.blocklyDiv .blocklyFlyout,
.blocklyDiv .blocklyToolboxDiv {
transition: opacity 0.2s ease-in-out;
}
.blocklyDiv:empty {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading-shimmer 1.5s infinite;
}
@media (max-width: 480px) {
.fixed.z-50 {
z-index: 1000 !important;
}
nav {
z-index: 100 !important;
}
.blocklyDiv,
.injectionDiv {
z-index: 10 !important;
position: relative !important;
}
.blocklyToolboxDiv {
z-index: 30 !important;
}
}
.blocklyTreeLabel svg {
vertical-align: middle;
margin-right: 4px;
}
/* ================================================================================================== */
.blocklyToolboxCategoryLabel {
font-size: 1.2em;
}
.blocklyToolboxContents {
padding: 0.5em;
}
.blocklyToolboxCategory {
padding: 0.5em;
margin-top: 0.5em;
margin-bottom: 0.2em;
margin-left: 0.5em;
margin-right: 0.5em;
border-radius: 10px;
border: 5px solid #ffffff;
}
.customIcon {
color: #fff;
}
.blocklyTreeRowContentContainer {
display: flex;
flex-direction: column;
align-items: center;
}
.blocklyToolboxCategory {
height: initial;
}
@media (max-width: 480px) {
.blocklyToolboxCategory {
padding: 0px;
border: none;
border-radius: 0%;
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 0;
width: 42px;
height: 40px;
/* Esconde o texto */
}
.blocklyToolboxCategoryLabel {
display: none;
/* Some completamente */
}
.blocklyTreeRowContentContainer {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
text-align: center !important;
width: 100% !important;
height: 100% !important;
}
.blocklyToolboxCategoryIcon {
/* font-size: 32px !important; */
margin: 0 !important;
color: white !important;
}
.blocklyText {
font-size: small !important;
}
}
.blockly-debug-solution-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 1000; /* Aumentado para ficar sobre tudo */
padding: 0.5rem;
background-color: white;
border: none;
border-radius: 9999px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition: background-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.blockly-debug-solution-btn:hover {
background-color: #f3e8ff;
}
.blockly-debug-solution-btn:active {
background-color: #e9d5ff;
}
@media (max-width: 768px) {
.blockly-debug-solution-btn {
top: 0.5rem;
right: 0.5rem;
padding: 0.5rem;
}
.blockly-debug-solution-btn svg {
width: 1.25rem;
height: 1.25rem;
}
}
@media (max-width: 480px) {
.blockly-debug-solution-btn {
top: 0.375rem;
right: 0.375rem;
padding: 0.375rem;
}
.blockly-debug-solution-btn svg {
width: 1rem;
height: 1rem;
}
}

View File

@@ -0,0 +1,201 @@
/**
* @fileoverview React component for CodeEditor.jsx
*
* @module components.game.editors.CodeEditor
*/
import React, { useState, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import CodeMirror from "@uiw/react-codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { autocompletion } from "@codemirror/autocomplete";
import { indentWithTab, insertTab } from "@codemirror/commands";
import { keymap } from "@codemirror/view";
import { indentOnInput, indentUnit } from "@codemirror/language";
import { useGameState } from "../../../contexts/GameStateContext";
import { useEditor } from "../../../contexts/EditorContext";
import {
loadCode,
createDebouncedCodeSave,
} from "../../../services/codestorage";
const createGameCompletion = (gameConfig) => {
return autocompletion({
override: [
(context) => {
const word = context.matchBefore(/\w*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
const allowedFunctions = gameConfig?.allowedFunctions || [];
const functionDocs = gameConfig?.functionDocumentation || {};
const allowedStructures = gameConfig?.allowedControlStructures || [];
const functionOptions = allowedFunctions.map((funcName) => {
const doc = functionDocs[funcName] || {};
return {
label: funcName,
type: "function",
info: doc.description || `Função ${funcName}`,
detail: doc.syntax || `${funcName}()`,
apply: doc.example || `${funcName}()`,
};
});
const structureOptions = allowedStructures.map((structure) => {
const templates = {
if: "if (${condition}) {\n ${code}\n}",
else: "else {\n ${code}\n}",
while: "while (${condition}) {\n ${code}\n}",
for: "for (${init}; ${condition}; ${update}) {\n ${code}\n}",
var: "var ${name} = ${value};",
function: "function ${name}() {\n ${code}\n}",
};
return {
label: structure,
type: "keyword",
info: `Estrutura de controle ${structure}`,
detail: templates[structure] || structure,
apply: templates[structure] || structure,
};
});
return {
from: word.from,
options: [...functionOptions, ...structureOptions],
};
},
],
});
};
export default function CodeEditor() {
const {
registerCodeEditorFunction,
onCodeEditorChange,
gameConfig,
currentPhase,
} = useGameState();
const { gameNameKey } = useEditor();
const phases = gameConfig?.fases ?? gameConfig?.phases; // support English alias
const phaseConfig = phases?.find((phase) => phase.id === currentPhase);
const initialCode = phaseConfig?.initialCode || "// Digite seu código aqui";
const [code, setCode] = useState(initialCode);
const [isLoading, setIsLoading] = useState(true);
const debouncedCodeSave = useMemo(() => createDebouncedCodeSave(1000), []);
const codeStorageKey = `${gameNameKey}-code`;
useEffect(() => {
setIsLoading(true);
const savedCode = loadCode(codeStorageKey);
if (savedCode !== null) {
setCode(savedCode);
} else {
setCode(initialCode);
}
setIsLoading(false);
}, [codeStorageKey, initialCode]);
useEffect(() => {
const getCodeFromEditor = () => {
return code;
};
registerCodeEditorFunction(getCodeFromEditor);
return () => {
registerCodeEditorFunction(null);
};
}, [code, registerCodeEditorFunction]);
useEffect(() => {
if (!isLoading) {
onCodeEditorChange(code);
}
}, [code, onCodeEditorChange, isLoading]);
const handleChange = (value) => {
setCode(value);
if (!isLoading) {
debouncedCodeSave(codeStorageKey, value);
}
};
useEffect(() => {
return () => {
debouncedCodeSave.cancel();
};
}, [debouncedCodeSave]);
return (
<div className="w-full h-full">
<CodeMirror
value={code}
height="100%"
extensions={[
javascript(),
createGameCompletion(gameConfig),
indentOnInput(),
indentUnit.of(" "), // 2 espaços para indentação
keymap.of([
indentWithTab,
{
key: "Enter",
run: ({ state, dispatch }) => {
const { from } = state.selection.main;
const line = state.doc.lineAt(from);
const lineText = line.text;
const indent = lineText.match(/^\s*/)[0];
let newIndent = indent;
if (lineText.trim().endsWith("{")) {
newIndent += " ";
}
dispatch(
state.update({
changes: {
from,
insert: "\n" + newIndent,
},
selection: { anchor: from + newIndent.length + 1 },
}),
);
return true;
},
},
]),
]}
onChange={handleChange}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
}}
className="h-full"
/>
</div>
);
}
CodeEditor.propTypes = {};

View File

@@ -0,0 +1,79 @@
/**
* @fileoverview Utility module for custom_category.js
*
* @module components.game.editors.custom_category
*/
import * as Blockly from "blockly/core";
class CustomCategory extends Blockly.ToolboxCategory {
constructor(categoryDef, toolbox, opt_parent) {
super(categoryDef, toolbox, opt_parent);
}
init() {
super.init();
// Agora o DOM está criado, pode alterar cor do texto e ícone
const labelDom = this.rowDiv_.getElementsByClassName(
"blocklyToolboxCategoryLabel",
)[0];
if (labelDom) {
labelDom.style.color = "white";
}
if (this.iconDom_) {
this.iconDom_.style.color = "white";
}
}
addColourBorder_(colour) {
this.rowDiv_.style.backgroundColor = colour;
}
setSelected(isSelected) {
const labelDom = this.rowDiv_.getElementsByClassName(
"blocklyToolboxCategoryLabel",
)[0];
if (isSelected) {
this.rowDiv_.style.backgroundColor = "white";
labelDom.style.color = this.colour_;
this.iconDom_.style.color = this.colour_;
} else {
this.rowDiv_.style.backgroundColor = this.colour_;
labelDom.style.color = "white";
this.iconDom_.style.color = "white";
}
Blockly.utils.aria.setState(
this.htmlDiv_,
Blockly.utils.aria.State.SELECTED,
isSelected,
);
}
createIconDom_() {
const iconClass = this.toolboxItemDef_["css-icon"];
if (iconClass) {
const iconElement = document.createElement("i");
iconElement.className = iconClass;
iconElement.style.fontSize = "18px";
iconElement.style.marginRight = "8px";
return iconElement;
}
// fallback
const iconImg = document.createElement("img");
iconImg.src = "./logo_only.svg";
iconImg.alt = "Blockly Logo";
iconImg.width = 20;
iconImg.height = 20;
return iconImg;
}
}
Blockly.registry.register(
Blockly.registry.Type.TOOLBOX_ITEM,
Blockly.ToolboxCategory.registrationName,
CustomCategory,
true,
);

View File

@@ -0,0 +1,48 @@
/**
* @fileoverview Utility module for toolboxIcons.js
*
* @module components.game.editors.toolboxIcons
*/
export function getCategoryIcon(name) {
switch (name) {
case "Movimento":
return "fa fa-arrows-alt";
case "Repetição":
return "fa fa-refresh";
case "Lógica":
return "fa fa-code";
case "Sensores":
return "fa fa-eye";
case "Cor":
return "fa fa-paint-brush";
case "Tempo":
return "fa fa-clock";
case "Semáforo Carros":
return "fa fa-traffic-light";
case "Semáforo Pedestre":
return "fa fa-walking";
case "Multimídia":
return "fa fa-music";
case "Matemática":
return "fa fa-calculator";
case "Condicionais":
return "fa fa-check";
case "Funções":
return "fa fa-cogs";
case "Cena":
return "fa fa-flag";
case "Eventos":
return "fa fa-bell";
case "Texto":
return "fa fa-font";
case "Listas":
return "fa fa-list";
case "Variáveis":
return "fa fa-database";
case "Caneta":
return "fa fa-pencil-alt";
default:
return "fa fa-cube";
}
}

View File

@@ -0,0 +1,44 @@
/**
* @fileoverview React component for CodeArea.jsx
*
* @module components.game.modal.CodeArea
*/
import React from "react";
import { Code } from "lucide-react";
import PropTypes from "prop-types";
export const CodeArea = ({
code,
title = "Código Gerado",
variant = "success",
}) => {
const titleToShow = title;
const textColors = {
success: "text-green-400",
failure: "text-red-300",
};
return (
<div className="mt-4">
<div className="flex items-center space-x-2 mb-3">
<Code className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-title font-medium text-gray-900">{titleToShow}</h3>
</div>
<div className="bg-gray-900 rounded-lg p-4 overflow-auto border border-gray-800">
<pre
className={`${textColors[variant]} text-sm font-mono whitespace-pre-wrap`}
>
{code || "Nenhum código disponível"}
</pre>
</div>
</div>
);
};
CodeArea.propTypes = {
code: PropTypes.any,
title: PropTypes.string,
variant: PropTypes.string,
};

View File

@@ -0,0 +1,38 @@
/**
* @fileoverview React component for FeedbackBox.jsx
*
* @module components.game.modal.FeedbackBox
*/
import React from "react";
import PropTypes from "prop-types";
export const FeedbackBox = ({ title, children, variant = "success" }) => {
const titleToShow = title;
const styles = {
success: "bg-green-50 border-green-200 text-green-800",
failure: "bg-amber-50 border-amber-200 text-amber-800",
};
const titleStyles = {
success: "text-green-900",
failure: "text-amber-900",
};
return (
<div className={`mt-6 p-4 rounded-lg border ${styles[variant]}`}>
<h4
className={`font-medium mb-2 flex items-center gap-2 ${titleStyles[variant]}`}
>
{variant === "success" ? "💡" : "⚠️"} {titleToShow}
</h4>
<div className="text-sm leading-relaxed">{children}</div>
</div>
);
};
FeedbackBox.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
variant: PropTypes.string,
};

View File

@@ -0,0 +1,31 @@
/**
* @fileoverview React component for ModalBase.jsx
*
* @module components.game.modal.ModalBase
*/
import React from "react";
import { X } from "lucide-react";
import PropTypes from "prop-types";
export const ModalBase = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={(e) => e.target === e.currentTarget && onClose && onClose()}
>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[85vh] flex flex-col overflow-hidden">
{children}
</div>
</div>
);
};
ModalBase.propTypes = {
isOpen: PropTypes.bool,
onClose: PropTypes.func,
children: PropTypes.node,
};

View File

@@ -0,0 +1,69 @@
/**
* @fileoverview React component for ModalHeader.jsx
*
* @module components.game.modal.ModalHeader
*/
import React from "react";
import { X, CheckCircle, AlertCircle } from "lucide-react";
import PropTypes from "prop-types";
export const ModalHeader = ({
title,
subTitle,
variant = "success",
onClose,
ariaCloseLabel,
}) => {
const subtitleText = subTitle;
// Mapeamento de estilos por variante
const config = {
success: {
bgColor: "bg-green-100",
iconColor: "text-green-600",
Icon: CheckCircle,
},
failure: {
bgColor: "bg-red-100",
iconColor: "text-red-600",
Icon: AlertCircle,
},
};
const { bgColor, iconColor, Icon } = config[variant];
return (
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 ${bgColor} rounded-full flex items-center justify-center`}
>
<Icon className={`w-6 h-6 ${iconColor}`} />
</div>
<div>
<h2 className="text-xl font-title font-semibold text-gray-900">{title}</h2>
<p className="text-sm font-title text-gray-600">{subtitleText}</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors cancel-icon"
aria-label={ariaCloseLabel ?? "Fechar"}
>
<X className="w-6 h-6" />
</button>
</div>
);
};
ModalHeader.propTypes = {
title: PropTypes.string,
subTitle: PropTypes.string,
variant: PropTypes.string,
onClose: PropTypes.func,
ariaCloseLabel: PropTypes.string,
};

View File

@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { letramentoEventBus, LETRAMENTO_EVENTS } from '../../utils/letramentoEvents';
// Allowed event types from the iframe activity.
// The HTML activity sends: window.parent.postMessage({ type: 'started' | 'running' | 'success' | 'failure' | 'completed', ...payload }, '*')
const ALLOWED_TYPES = new Set(Object.values(LETRAMENTO_EVENTS));
function generateChannelToken() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `ltr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function resolveHtmlFile(htmlFile) {
if (/^https?:\/\//.test(htmlFile) || /^file:\/\//.test(htmlFile)) {
return htmlFile;
}
if (window.location.protocol === 'file:') {
const relativePath = htmlFile.startsWith('/') ? htmlFile.slice(1) : htmlFile;
return new URL(relativePath, window.location.href).toString();
}
return htmlFile.startsWith('/') ? htmlFile : `/${htmlFile}`;
}
function buildIframeSrc(htmlFile, channelToken) {
const [base, existingHash = ''] = htmlFile.split('#');
const resolvedBase = resolveHtmlFile(base);
const hashParams = new URLSearchParams(existingHash);
hashParams.set('channelToken', channelToken);
return `${resolvedBase}#${hashParams.toString()}`;
}
export default function AtividadeRuntimeFrame({ htmlFile, atividadeId, reloadToken = 0, className = '' }) {
const iframeRef = useRef(null);
const channelToken = useMemo(
() => generateChannelToken(),
[atividadeId, htmlFile, reloadToken],
);
const iframeSrc = useMemo(
() => buildIframeSrc(htmlFile, channelToken),
[htmlFile, channelToken],
);
const focusRuntime = () => {
const iframe = iframeRef.current;
if (!iframe) return;
iframe.focus();
iframe.contentWindow?.focus();
};
useEffect(() => {
const handleMessage = (event) => {
const iframeWindow = iframeRef.current?.contentWindow;
if (!iframeWindow || event.source !== iframeWindow) return;
// Accept only messages from the same origin or from sandboxed iframes.
// Sandboxed iframes (sandbox="allow-scripts" without allow-same-origin) always
// report event.origin as the string "null" — we allow that explicitly.
const allowedOrigin =
event.origin === window.location.origin || event.origin === 'null';
if (!allowedOrigin) return;
const { type, token, ...payload } = event.data ?? {};
if (token !== channelToken) return;
if (!type || !ALLOWED_TYPES.has(type)) return;
letramentoEventBus.dispatch(type, { atividadeId, ...payload });
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [atividadeId, channelToken]);
useEffect(() => {
// Focus right after mount/switch so keyboard-only users can start immediately.
const raf = window.requestAnimationFrame(() => {
focusRuntime();
window.setTimeout(focusRuntime, 50);
});
return () => window.cancelAnimationFrame(raf);
}, [atividadeId, htmlFile, reloadToken]);
return (
<iframe
key={`${atividadeId}:${reloadToken}`}
ref={iframeRef}
src={iframeSrc}
title="Atividade de Letramento Digital"
className={`w-full h-full border-0 ${className}`}
tabIndex={-1}
onLoad={focusRuntime}
// allow-same-origin enables file:// subresource loading in Electron builds.
sandbox="allow-scripts allow-same-origin"
referrerPolicy="no-referrer"
/>
);
}
AtividadeRuntimeFrame.propTypes = {
htmlFile: PropTypes.string.isRequired,
atividadeId: PropTypes.string.isRequired,
reloadToken: PropTypes.number,
className: PropTypes.string,
};

View File

@@ -0,0 +1,98 @@
import { useEffect } from 'react';
import { CheckCircle, XCircle, RotateCcw, ArrowRight } from 'lucide-react';
import PropTypes from 'prop-types';
import { ATIVIDADE_STATES } from '../../contexts/LetramentoStateContext';
export default function AtividadeStatusModal({ status, isOpen, onTryAgain, onDismiss, nextAtividadeId, onNextAtividade }) {
const isSuccess = status === ATIVIDADE_STATES.SUCCESS || status === ATIVIDADE_STATES.COMPLETED;
const isFailure = status === ATIVIDADE_STATES.FAILURE;
useEffect(() => {
if (!isOpen || (!isSuccess && !isFailure)) return undefined;
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
onDismiss();
return;
}
if (event.key === 'Enter' && isSuccess && nextAtividadeId && onNextAtividade) {
event.preventDefault();
onNextAtividade();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFailure, isOpen, isSuccess, nextAtividadeId, onDismiss, onNextAtividade]);
if (!isOpen || (!isSuccess && !isFailure)) return null;
return (
<div className="fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div
className="bg-white/95 backdrop-blur-sm border border-white/30 rounded-xl shadow-xl w-full max-w-md flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header — identical to SeletorDeFases */}
<div className="flex items-center justify-between p-6 border-b border-white/20">
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-500'}`}>
{isSuccess ? <CheckCircle className="w-5 h-5" /> : <XCircle className="w-5 h-5" />}
</div>
<h3 className="text-xl font-title font-bold text-gray-800">
{isSuccess ? 'Atividade concluída' : 'Tente novamente'}
</h3>
</div>
</div>
{/* Body */}
<div className="p-6 flex flex-col items-center gap-3 text-center">
<p className="text-gray-700 text-base">
{isSuccess
? 'Você concluiu esta atividade com sucesso.'
: 'Não desanime. Você consegue tentar outra vez.'}
</p>
</div>
{/* Footer — identical to SeletorDeFases */}
<div className="p-4 border-t border-white/20 flex justify-center gap-3 flex-wrap">
{isFailure && (
<button
onClick={onTryAgain}
className="flex items-center gap-2 bg-brand-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-brand-200"
>
<RotateCcw className="w-4 h-4" />
Tentar Novamente
</button>
)}
{isSuccess && nextAtividadeId && onNextAtividade && (
<button
onClick={onNextAtividade}
className="flex items-center gap-2 bg-green-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-green-200"
>
Próxima Atividade
<ArrowRight className="w-4 h-4" />
</button>
)}
<button
onClick={onDismiss}
className="bg-gray-100 text-black px-6 py-2 rounded-full font-semibold shadow transition-all duration-200 hover:bg-gray-200"
>
Voltar à Lista
</button>
</div>
</div>
</div>
);
}
AtividadeStatusModal.propTypes = {
status: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
onTryAgain: PropTypes.func.isRequired,
onDismiss: PropTypes.func.isRequired,
nextAtividadeId: PropTypes.string,
onNextAtividade: PropTypes.func,
};

View File

@@ -0,0 +1,204 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { listarAtividadesPorCategoria } from '../../atividades/letramento/letramentoRegistry';
import { getCompletedPhases } from '../../services/letramentoStorage';
import { getUnlockedAtividades } from '../../utils/phaseUtils';
import { MousePointer, Keyboard, Lock, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
const CATEGORIA_UI = {
mouse: {
Icon: MousePointer,
headerClass: 'bg-red-50',
iconWrapClass: 'bg-red-600',
currentClass: 'bg-red-50 border-red-500 ring-red-200',
currentBadgeClass: 'bg-red-600 text-white',
completedClass: 'bg-green-50 border-green-300 hover:border-green-400 hover:shadow',
completedBadgeClass: 'bg-green-100 text-green-600',
hoverBorderClass: 'hover:border-red-300',
},
teclado: {
Icon: Keyboard,
headerClass: 'bg-brand-50',
iconWrapClass: 'bg-brand-600',
currentClass: 'bg-brand-50 border-brand-500 ring-brand-200',
currentBadgeClass: 'bg-brand-600 text-white',
completedClass: 'bg-brand-50 border-brand-300 hover:border-brand-400 hover:shadow',
completedBadgeClass: 'bg-brand-100 text-brand-700',
hoverBorderClass: 'hover:border-brand-300',
},
};
/**
* Sidebar com lista de atividades da mesma categoria
* Mostra progresso e permite navegação
*/
export default function AtividadesSidebar({ atividadeAtual, categoria, onChangeAtividade, currentAtividadeCompleted = false }) {
const [isCollapsed, setIsCollapsed] = useState(false);
const atividades = listarAtividadesPorCategoria(categoria);
const categoriaUi = CATEGORIA_UI[categoria] ?? CATEGORIA_UI.mouse;
// Busca array de atividades completadas (similar ao SeletorDeFases)
const completedPhases = getCompletedPhases(categoria);
const optimisticCompleted = currentAtividadeCompleted
? Array.from(new Set([...completedPhases, atividadeAtual]))
: completedPhases;
const unlockedPhases = getUnlockedAtividades(optimisticCompleted, atividades);
const atividadesDesbloqueadas = new Set(unlockedPhases);
const handleAtividadeClick = (atividadeId) => {
if (atividadesDesbloqueadas.has(atividadeId) && atividadeId !== atividadeAtual) {
onChangeAtividade(atividadeId);
}
};
const CategoriaIcon = categoriaUi.Icon;
return (
<div className={`bg-white/95 backdrop-blur-sm border-r border-gray-200 flex flex-col overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}>
{/* Header da categoria */}
<div className={`p-4 border-b border-gray-200 ${categoriaUi.headerClass}`}>
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${categoriaUi.iconWrapClass}`}>
<CategoriaIcon className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="font-semibold text-gray-800 capitalize">{categoria}</h3>
<p className="text-xs text-gray-600">{atividades.length} atividades</p>
</div>
</div>
)}
{isCollapsed && (
<div className="flex justify-center">
<div className={`p-2 rounded-lg ${categoriaUi.iconWrapClass}`}>
<CategoriaIcon className="w-5 h-5 text-white" />
</div>
</div>
)}
</div>
{/* Lista de atividades */}
<div className="flex-1 overflow-y-auto p-2">
{atividades.map((atividade, index) => {
const isAtual = atividade.id === atividadeAtual;
const isDesbloqueada = atividadesDesbloqueadas.has(atividade.id);
const isCompletada = optimisticCompleted.includes(atividade.id);
return (
<button
key={atividade.id}
onClick={() => handleAtividadeClick(atividade.id)}
disabled={!isDesbloqueada}
className={`
w-full mb-2 rounded-lg border-2 transition-all
${isCollapsed ? 'p-2' : 'p-3'}
${isAtual
? `${categoriaUi.currentClass} ring-2 shadow-md`
: isCompletada
? categoriaUi.completedClass
: isDesbloqueada
? `bg-white border-gray-200 ${categoriaUi.hoverBorderClass} hover:shadow`
: 'bg-gray-50 border-gray-200 cursor-not-allowed opacity-60'
}
`}
title={isCollapsed ? `${atividade.titulo} - ${atividade.descricao}` : ''}
>
{isCollapsed ? (
// Versão colapsada - apenas ícone/número
<div className="flex justify-center">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
${isAtual
? categoriaUi.currentBadgeClass
: isCompletada
? categoriaUi.completedBadgeClass
: isDesbloqueada
? 'bg-gray-200 text-gray-700'
: 'bg-gray-100 text-gray-400'
}
`}>
{isCompletada ? (
<CheckCircle className="w-5 h-5" />
) : !isDesbloqueada ? (
<Lock className="w-4 h-4" />
) : (
index + 1
)}
</div>
</div>
) : (
// Versão expandida - conteúdo completo
<div className="flex items-start gap-3">
{/* Número ou status icon */}
<div className={`
flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
${isAtual
? categoriaUi.currentBadgeClass
: isCompletada
? categoriaUi.completedBadgeClass
: isDesbloqueada
? 'bg-gray-200 text-gray-700'
: 'bg-gray-100 text-gray-400'
}
`}>
{isCompletada ? (
<CheckCircle className="w-5 h-5" />
) : !isDesbloqueada ? (
<Lock className="w-4 h-4" />
) : (
index + 1
)}
</div>
{/* Título e descrição */}
<div className="flex-1 text-left">
<h4 className={`
text-sm font-semibold mb-1
${isAtual ? 'text-gray-800' : isDesbloqueada ? 'text-gray-800' : 'text-gray-400'}
`}>
{atividade.titulo}
</h4>
<p className={`
text-xs line-clamp-2
${isAtual ? 'text-gray-600' : isDesbloqueada ? 'text-gray-600' : 'text-gray-400'}
`}>
{atividade.descricao}
</p>
</div>
</div>
)}
</button>
);
})}
</div>
{/* Footer com botão de colapsar */}
<div className="border-t border-gray-200 p-3 bg-gray-50">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white hover:bg-gray-100 border border-gray-300 rounded-lg transition-colors"
title={isCollapsed ? 'Expandir menu' : 'Recolher menu'}
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-gray-600" />
) : (
<>
<ChevronLeft className="w-5 h-5 text-gray-600" />
<span className="text-sm text-gray-600 font-medium">Recolher</span>
</>
)}
</button>
</div>
</div>
);
}
AtividadesSidebar.propTypes = {
atividadeAtual: PropTypes.string.isRequired,
categoria: PropTypes.string.isRequired,
onChangeAtividade: PropTypes.func.isRequired,
currentAtividadeCompleted: PropTypes.bool,
};

View File

@@ -0,0 +1,29 @@
import { Monitor, X } from 'lucide-react';
import PropTypes from 'prop-types';
export default function LetramentoNavBar({ titulo, onClose }) {
return (
<div className="flex items-center justify-between p-6 border-b border-white/20">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center text-brand-600">
<Monitor className="w-5 h-5" />
</div>
<h3 className="text-xl font-title font-bold text-gray-800">
{titulo}
</h3>
</div>
<button
onClick={onClose}
className="w-8 h-8 cancel-icon bg-gray-200 hover:bg-gray-300 flex items-center justify-center text-gray-600 transition-colors"
aria-label="Fechar atividade"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
LetramentoNavBar.propTypes = {
titulo: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,63 @@
import { Check } from 'lucide-react';
import PropTypes from 'prop-types';
// Displays a horizontal step trail — Windows wizard style.
// currentStep is 1-based (matches the step numbers sent by the activity).
// 0 = not started.
export default function TrilhaPassos({ passos, currentStep, completed }) {
return (
<div className="flex items-center gap-0 border-b border-white/20 px-6 py-3 overflow-x-auto">
{passos.map((passo, idx) => {
const stepNum = idx + 1;
const isDone = completed || stepNum < currentStep;
const isActive = !completed && stepNum === currentStep;
return (
<div key={passo.id} className="flex items-center">
{/* step circle */}
<div className="flex flex-col items-center gap-1 min-w-[80px]">
<div
className={[
'w-9 h-9 rounded-full flex items-center justify-center border-2 font-bold text-sm transition-colors',
isDone
? 'bg-green-600 border-green-600 text-white'
: isActive
? 'bg-brand-500 border-brand-500 text-white'
: 'bg-white border-gray-400 text-gray-400',
].join(' ')}
>
{isDone ? <Check className="w-4 h-4" /> : stepNum}
</div>
<span
className={[
'text-xs text-center leading-tight whitespace-nowrap',
isDone ? 'text-green-700 font-medium' : isActive ? 'text-brand-500 font-semibold' : 'text-gray-400',
].join(' ')}
>
{passo.label}
</span>
</div>
{/* connector */}
{idx < passos.length - 1 && (
<div
className={[
'h-0.5 w-8 mx-1 mb-5 flex-shrink-0 transition-colors',
isDone ? 'bg-green-500' : 'bg-gray-300',
].join(' ')}
/>
)}
</div>
);
})}
</div>
);
}
TrilhaPassos.propTypes = {
passos: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.number, label: PropTypes.string })).isRequired,
currentStep: PropTypes.number.isRequired,
completed: PropTypes.bool,
};
TrilhaPassos.defaultProps = { completed: false };

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import AtividadeRuntimeFrame from '../AtividadeRuntimeFrame';
import { letramentoEventBus } from '../../../utils/letramentoEvents';
function getRenderedIframe() {
return screen.getByTitle('Atividade de Letramento Digital');
}
function getChannelToken(iframe) {
const iframeUrl = new URL(iframe.getAttribute('src'), 'http://localhost');
return new URLSearchParams(iframeUrl.hash.slice(1)).get('channelToken');
}
describe('AtividadeRuntimeFrame', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('despacha eventos validos enviados pelo iframe com token correto', () => {
const dispatchSpy = vi.spyOn(letramentoEventBus, 'dispatch');
render(
<AtividadeRuntimeFrame
htmlFile="/atividades/letramento/teste/index.html"
atividadeId="atividade-segura"
/>,
);
const iframe = getRenderedIframe();
const token = getChannelToken(iframe);
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'completed', token, score: 100 },
origin: 'null',
source: iframe.contentWindow,
}),
);
expect(dispatchSpy).toHaveBeenCalledWith('completed', {
atividadeId: 'atividade-segura',
score: 100,
});
});
it('ignora mensagens com token invalido', () => {
const dispatchSpy = vi.spyOn(letramentoEventBus, 'dispatch');
render(
<AtividadeRuntimeFrame
htmlFile="/atividades/letramento/teste/index.html"
atividadeId="atividade-segura"
/>,
);
const iframe = getRenderedIframe();
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'completed', token: 'token-invalido', score: 100 },
origin: 'null',
source: iframe.contentWindow,
}),
);
expect(dispatchSpy).not.toHaveBeenCalled();
});
it('ignora mensagens vindas de outra source', () => {
const dispatchSpy = vi.spyOn(letramentoEventBus, 'dispatch');
render(
<AtividadeRuntimeFrame
htmlFile="/atividades/letramento/teste/index.html"
atividadeId="atividade-segura"
/>,
);
const iframe = getRenderedIframe();
const token = getChannelToken(iframe);
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'completed', token, score: 100 },
origin: 'null',
source: window,
}),
);
expect(dispatchSpy).not.toHaveBeenCalled();
});
});