diff --git a/.gitignore b/.gitignore
index 85e3640..6c404a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -144,4 +144,10 @@ deploy_homolog.sh
/SDD
app/.vscode/settings.json
-app/specs
\ No newline at end of file
+app/specs
+# Environment variables - never commit secrets
+.env
+.env.*.local
+app/.env.local
+app/.env.production
+app/.env.offline
diff --git a/README.md b/README.md
index a61f7ad..97321a6 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,51 @@ plataforma-edu-docs-1 Up 2 minutes
plataforma-edu-proxy-1 Up 2 minutes 0.0.0.0:80->80/tcp
```
+#### Build com Google Analytics 4 (GA4)
+
+Para ativar analytics em produção, use argumentos de build:
+
+**Com variáveis de ambiente:**
+
+Crie um arquivo `.env` na raiz do projeto:
+
+```bash
+GIT_COMMIT_HASH=abc1234
+VITE_ANALYTICS_PROVIDER=ga4
+VITE_GA4_ID=G-SEU_ID_GA4
+VITE_GA4_DEBUG=false
+```
+
+Então execute:
+
+```bash
+docker compose build app
+docker compose up -d
+```
+
+**Ou diretamente via argumentos:**
+
+```bash
+docker compose build \
+ --build-arg GIT_COMMIT_HASH=$(git rev-parse --short HEAD) \
+ --build-arg VITE_ANALYTICS_PROVIDER=ga4 \
+ --build-arg VITE_GA4_ID=SEU_ID_AQUI \
+ app
+
+docker compose up -d
+```
+
+**Para desabilitar analytics (desenvolvimento):**
+
+```bash
+docker compose build --build-arg VITE_ANALYTICS_PROVIDER=noop app
+docker compose up -d
+```
+
+> 📊 **Nota:** Obtém seu ID do GA4 em [https://analytics.google.com](https://analytics.google.com) → Administração → Data Streams → Copie o ID de medição (formato: G-XXXXXXXXXX)
+
+Para mais detalhes, veja: [Documentação de Analytics](docs/docs/plataforma/arquitetura/analytics.md)
+
---
### Opção 2: Desenvolvimento Local
diff --git a/app/.dockerignore b/app/.dockerignore
new file mode 100644
index 0000000..ee0d069
--- /dev/null
+++ b/app/.dockerignore
@@ -0,0 +1,23 @@
+node_modules
+npm-debug.log
+dist
+.git
+.gitignore
+.github
+.vscode
+.env
+.env.local
+.env.production
+.env.offline
+*.md
+docs
+jupyter
+SDD
+.specify
+.specs
+.cache
+.pnpmfile.cjs
+.npmrc
+coverage
+.nyc_output
+.vercel
diff --git a/app/.env.example b/app/.env.example
new file mode 100644
index 0000000..ec516c2
--- /dev/null
+++ b/app/.env.example
@@ -0,0 +1,45 @@
+# ============================================================================
+# ANALYTICS CONFIGURATION
+# ============================================================================
+
+# Provider: 'ga4' para Google Analytics 4, ou 'noop' para desabilitar
+# Padrão: 'ga4' em produção, 'noop' em desenvolvimento
+VITE_ANALYTICS_PROVIDER=ga4
+
+# Google Analytics 4 - ID de Medição (ID do fluxo de dados)
+# Obtenha em: https://analytics.google.com → Administração → Data Streams
+# Formato: G-XXXXXXXXXX
+# Deixe em branco para desabilitar GA4 (mesmo que VITE_ANALYTICS_PROVIDER=ga4)
+VITE_GA4_ID=G-57HGKF773M
+
+# Debug mode: 'true' para ativar logs de eventos no console do browser
+# Útil para testing e desenvolvimento
+VITE_GA4_DEBUG=false
+
+# Banner de consentimento de cookies (LGPD/GDPR)
+# 'true' para mostrar, 'false' para desabilitar
+VITE_ENABLE_CONSENT_BANNER=true
+
+# ============================================================================
+# BUILD CONFIGURATION
+# ============================================================================
+
+# Git commit hash - usado para versionamento e detecção de atualizações
+# Deixe em branco para usar 'unknown' durante o build
+# Será preenchido automaticamente em CI/CD
+GIT_COMMIT_HASH=
+
+# ============================================================================
+# NOTAS
+# ============================================================================
+#
+# Para desenvolvimento (sem analytics):
+# VITE_ANALYTICS_PROVIDER=noop
+#
+# Para produção (com GA4):
+# VITE_ANALYTICS_PROVIDER=ga4
+# VITE_GA4_ID=G-SEU_ID_AQUI
+# VITE_GA4_DEBUG=false
+#
+# Veja ANALYTICS.md e DOCKER_BUILD_EXAMPLES.md para mais detalhes
+#
diff --git a/app/Dockerfile b/app/Dockerfile
index 4cc47da..a19fdce 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -3,23 +3,28 @@ FROM node:20-alpine AS builder
WORKDIR /app
ARG GIT_COMMIT_HASH=unknown
-ARG APP_VERSION=1.1.2
+ARG APP_VERSION=1.1.3
+ARG VITE_ANALYTICS_PROVIDER=ga4
+ARG VITE_GA4_ID=G-57HGKF773M
+ARG VITE_GA4_DEBUG=false
+
+ENV NODE_ENV=production
ENV VITE_APP_VERSION=$APP_VERSION
ENV VITE_GIT_HASH=$GIT_COMMIT_HASH
-
-RUN npm install -g pnpm
+ENV VITE_ANALYTICS_PROVIDER=$VITE_ANALYTICS_PROVIDER
+ENV VITE_GA4_ID=$VITE_GA4_ID
+ENV VITE_GA4_DEBUG=$VITE_GA4_DEBUG
COPY package.json pnpm-lock.yaml ./
-RUN pnpm install
+RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
-RUN echo "{\"version\": \"$APP_VERSION\", \"commit\": \"$GIT_COMMIT_HASH\", \"buildDate\": \"$(date)\"}" > public/version.json
-
-RUN pnpm run build
+RUN echo "{\"version\": \"$APP_VERSION\", \"commit\": \"$GIT_COMMIT_HASH\", \"buildDate\": \"$(date)\"}" > public/version.json && \
+ pnpm run build && \
+ rm -rf node_modules .pnpm-store
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
-
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
diff --git a/app/index.html b/app/index.html
index 7869230..d3baf2e 100644
--- a/app/index.html
+++ b/app/index.html
@@ -6,6 +6,26 @@
Decoda
+
diff --git a/app/package.json b/app/package.json
index 7a27d14..bd56f59 100644
--- a/app/package.json
+++ b/app/package.json
@@ -2,7 +2,7 @@
"name": "decoda",
"private": true,
"description": "Aplicação educacional desenvolvida para ensino de programação básica e letramento digital",
- "version": "1.1.2",
+ "version": "1.1.3",
"main": "main.cjs",
"homepage": "./",
"type": "module",
diff --git a/app/src/App.jsx b/app/src/App.jsx
index d56cb15..9a0405a 100644
--- a/app/src/App.jsx
+++ b/app/src/App.jsx
@@ -4,12 +4,108 @@
* @module App
*/
-import { lazy, Suspense } from "react";
+import { lazy, Suspense, useEffect, useState } from "react";
import { HashRouter as Router, Routes, Route, useLocation } from "react-router-dom";
import "./App.css";
import HomePage from "./pages/HomePage/HomePage";
import LabPython from "./pages/LabPython/LabPython";
import ScrollToTop from "./components/ScrollToTop";
+import { getAnalytics, usePageTracking } from "./services/analytics";
+import { initializeAnalytics, analyticsConfig } from "./services/analytics";
+
+// Inline CookieBanner para evitar bloqueio do Brave
+function CookieBanner() {
+ const [visible, setVisible] = useState(false);
+ const enableBanner = import.meta.env.VITE_ENABLE_CONSENT_BANNER === 'true';
+
+ useEffect(() => {
+ if (!enableBanner) return;
+
+ try {
+ const stored = localStorage.getItem('decoda_consent');
+ if (!stored) {
+ setVisible(true);
+ }
+ } catch (e) {
+ console.error('Consent check failed:', e);
+ }
+ }, [enableBanner]);
+
+ const handleAccept = () => {
+ try {
+ localStorage.setItem('decoda_consent', JSON.stringify({
+ version: '1',
+ accepted: true,
+ timestamp: new Date().toISOString(),
+ }));
+ getAnalytics()._setConsentGrantedInternal(true);
+ setVisible(false);
+ } catch (e) {
+ console.error('Failed to accept consent:', e);
+ }
+ };
+
+ const handleReject = () => {
+ try {
+ localStorage.setItem('decoda_consent', JSON.stringify({
+ version: '1',
+ accepted: false,
+ timestamp: new Date().toISOString(),
+ }));
+ getAnalytics()._setConsentGrantedInternal(false);
+ setVisible(false);
+ } catch (e) {
+ console.error('Failed to reject consent:', e);
+ }
+ };
+
+ if (!visible || !enableBanner) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ Sua privacidade é importante
+
+
+ Utilizamos dados sobre como você usa a plataforma para melhorar a experiência educacional
+ e entender quais recursos são mais úteis.{' '}
+
+ Saiba mais
+
+
+
+
+
+ Rejeitar
+
+
+ Aceitar
+
+
+
+
+
+
+
+ );
+}
const Playground = lazy(() => import("./pages/Playground/Playground"));
const About = lazy(() => import("./pages/About/About"));
@@ -20,6 +116,11 @@ const Iniciativas = lazy(() => import("./pages/Iniciativas/Iniciativas"));
const IniciativaDetalhe = lazy(() => import("./pages/Iniciativas/IniciativaDetalhe"));
const PrimeirosPassos = lazy(() => import("./pages/PrimeirosPassos/PrimeirosPassos"));
const CategoriaLetramentoView = lazy(() => import("./pages/PrimeirosPassos/CategoriaLetramentoView"));
+const PrivacyPolicy = lazy(() =>
+ import("./pages/PrivacyPolicy/PrivacyPolicy").then(m => ({
+ default: m.PrivacyPolicy
+ }))
+);
//Atividades
const AspiradorGame = lazy(() => import("./atividades/programacao/aspirador/AspiradorGame"));
@@ -61,13 +162,15 @@ const LoadingFallback = () => (
);
// Separated so we can call useLocation (requires being inside Router)
-function AppRoutes() {
+function AppRoutes({ analyticsReady = false }) {
const location = useLocation();
// When navigating to a letramento category, the caller passes
// { state: { backgroundLocation: location } } so the previous page
// keeps rendering behind the modal overlay.
const backgroundLocation = location.state?.backgroundLocation;
+ usePageTracking(analyticsReady);
+
return (
<>
@@ -79,6 +182,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -107,11 +211,25 @@ function AppRoutes() {
}
export default function App() {
+ const [analyticsReady, setAnalyticsReady] = useState(false);
+
+ useEffect(() => {
+ initializeAnalytics({
+ providerType: analyticsConfig.providerType,
+ measurementId: analyticsConfig.measurementId,
+ hasConsent: true,
+ debugMode: analyticsConfig.debugMode,
+ });
+
+ setAnalyticsReady(true);
+ }, []);
+
return (
}>
-
+
+
);
}
diff --git a/app/src/atividades/letramento/shared/letramento.css b/app/src/atividades/letramento/shared/letramento.css
index 85645e6..d364920 100644
--- a/app/src/atividades/letramento/shared/letramento.css
+++ b/app/src/atividades/letramento/shared/letramento.css
@@ -616,6 +616,10 @@ video {
pointer-events: none;
}
+.\!visible {
+ visibility: visible !important;
+}
+
.visible {
visibility: visible;
}
@@ -984,10 +988,6 @@ video {
height: 0.25rem;
}
-.h-1\.5 {
- height: 0.375rem;
-}
-
.h-10 {
height: 2.5rem;
}
@@ -2885,10 +2885,6 @@ video {
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
}
-.bg-gray-200\/50 {
- background-color: rgb(229 231 235 / 0.5);
-}
-
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
@@ -4369,6 +4365,10 @@ video {
--tw-gradient-to: #ec4899 var(--tw-gradient-to-position);
}
+.to-pink-600 {
+ --tw-gradient-to: #db2777 var(--tw-gradient-to-position);
+}
+
.to-purple-100 {
--tw-gradient-to: #f3e8ff var(--tw-gradient-to-position);
}
@@ -6940,6 +6940,10 @@ video {
color: rgb(161 98 7 / 0.95);
}
+.underline {
+ text-decoration-line: underline;
+}
+
.underline-offset-4 {
text-underline-offset: 4px;
}
@@ -7125,10 +7129,6 @@ video {
transition-duration: 150ms;
}
-.duration-1000 {
- transition-duration: 1000ms;
-}
-
.duration-150 {
transition-duration: 150ms;
}
@@ -7149,10 +7149,6 @@ video {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
-.ease-out {
- transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
-}
-
.\[-webkit-text-stroke\:2px_black\] {
-webkit-text-stroke: 2px black;
}
@@ -7367,6 +7363,11 @@ video {
color: rgb(37 99 235 / var(--tw-text-opacity, 1));
}
+.hover\:text-blue-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(29 78 216 / var(--tw-text-opacity, 1));
+}
+
.hover\:text-gray-300:hover {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
@@ -7382,6 +7383,11 @@ video {
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
+.hover\:text-red-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(185 28 28 / var(--tw-text-opacity, 1));
+}
+
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -7627,6 +7633,10 @@ video {
width: 5rem;
}
+ .md\:w-auto {
+ width: auto;
+ }
+
.md\:max-w-\[378\.67px\] {
max-width: 378.67px;
}
@@ -7671,6 +7681,18 @@ video {
gap: 1.75rem;
}
+ .md\:gap-8 {
+ gap: 2rem;
+ }
+
+ .md\:p-6 {
+ padding: 1.5rem;
+ }
+
+ .md\:p-8 {
+ padding: 2rem;
+ }
+
.md\:px-0 {
padding-left: 0px;
padding-right: 0px;
@@ -7709,6 +7731,11 @@ video {
padding-top: 3rem;
}
+ .md\:text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ }
+
.md\:text-5xl {
font-size: 3rem;
line-height: 1;
diff --git a/app/src/components/CookieBanner/CookieBanner.css b/app/src/components/CookieBanner/CookieBanner.css
new file mode 100644
index 0000000..caa37fb
--- /dev/null
+++ b/app/src/components/CookieBanner/CookieBanner.css
@@ -0,0 +1 @@
+/* CookieBanner styles are now handled by Tailwind CSS in the JSX component */
diff --git a/app/src/components/CookieBanner/CookieBanner.jsx b/app/src/components/CookieBanner/CookieBanner.jsx
new file mode 100644
index 0000000..dff6d6b
--- /dev/null
+++ b/app/src/components/CookieBanner/CookieBanner.jsx
@@ -0,0 +1,90 @@
+import { useEffect, useState } from 'react';
+
+export function CookieBanner() {
+ const [visible, setVisible] = useState(false);
+ const [acceptConsent, rejectConsent] = [null, null];
+
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem('decoda_consent');
+ if (!stored) {
+ setVisible(true);
+ }
+ } catch (e) {
+ console.error('Consent check failed:', e);
+ }
+ }, []);
+
+ const handleAccept = () => {
+ try {
+ localStorage.setItem('decoda_consent', JSON.stringify({
+ version: '1',
+ accepted: true,
+ timestamp: new Date().toISOString(),
+ }));
+ setVisible(false);
+ } catch (e) {
+ console.error('Failed to accept consent:', e);
+ }
+ };
+
+ const handleReject = () => {
+ try {
+ localStorage.setItem('decoda_consent', JSON.stringify({
+ version: '1',
+ accepted: false,
+ timestamp: new Date().toISOString(),
+ }));
+ setVisible(false);
+ } catch (e) {
+ console.error('Failed to reject consent:', e);
+ }
+ };
+
+ if (!visible) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ Sua privacidade é importante
+
+
+ Utilizamos dados sobre como você usa a plataforma para melhorar a experiência educacional
+ e entender quais recursos são mais úteis.{' '}
+
+ Saiba mais
+
+
+
+
+
+ Rejeitar
+
+
+ Aceitar
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/game/GameBase.jsx b/app/src/components/game/GameBase.jsx
index 0df393d..9f10b31 100644
--- a/app/src/components/game/GameBase.jsx
+++ b/app/src/components/game/GameBase.jsx
@@ -146,7 +146,7 @@ function GameBaseContent({
currentPhase={currentPhase}
gameConfig={gameConfig}
onChangePhase={(fase) => {
- setCurrentPhase(fase);
+ setCurrentPhase(fase, "manual_selector");
setModalFasesAberto(false);
}}
onResetProgress={handleResetProgresso}
diff --git a/app/src/contexts/GameProgressContext.jsx b/app/src/contexts/GameProgressContext.jsx
index 5673b6a..a161a8c 100644
--- a/app/src/contexts/GameProgressContext.jsx
+++ b/app/src/contexts/GameProgressContext.jsx
@@ -44,10 +44,21 @@ export function GameProgressProvider({ children, gameConfig }) {
// Carrega progresso salvo na montagem; aplica debug mode se necessário.
useEffect(() => {
- const urlParams = new URLSearchParams(window.location.search);
- const debugKey = Array.from(urlParams.keys()).find(
+ // Try both window.location.search and hash-based query params (for HashRouter)
+ let urlParams = new URLSearchParams(window.location.search);
+ let debugKey = Array.from(urlParams.keys()).find(
(k) => k.toLowerCase() === "debug",
);
+
+ // If not found in search, try hash (for HashRouter with ?debug=true after hash)
+ if (!debugKey && window.location.hash.includes('?')) {
+ const hashSearch = window.location.hash.split('?')[1];
+ urlParams = new URLSearchParams(hashSearch);
+ debugKey = Array.from(urlParams.keys()).find(
+ (k) => k.toLowerCase() === "debug",
+ );
+ }
+
const isDebug = urlParams.get(debugKey)?.toLowerCase() === "true";
if (isDebug) {
diff --git a/app/src/contexts/GameStateContext.jsx b/app/src/contexts/GameStateContext.jsx
index c984618..aefaab3 100644
--- a/app/src/contexts/GameStateContext.jsx
+++ b/app/src/contexts/GameStateContext.jsx
@@ -16,6 +16,8 @@ import React, {
import PropTypes from "prop-types";
import { gameEventBus } from "../utils/gameEvents";
import { GameProgressProvider, useGameProgress } from "./GameProgressContext";
+import { useGameActivityTracking } from "../services/analytics/useGameActivityTracking";
+import { getAnalytics } from "../services/analytics/AnalyticsManager";
export const GAME_STATES = {
PARADO: "parado",
@@ -83,16 +85,53 @@ function GameStateInnerProvider({ children, gameConfig }) {
const [codeEditorContent, setCodeEditorContent] = useState("");
const [failureMessage, setFailureMessage] = useState("");
const [isDebugMode, setIsDebugMode] = useState(() => {
- const urlParams = new URLSearchParams(window.location.search);
- const debugKey = Array.from(urlParams.keys()).find(
+ // Try both window.location.search and hash-based query params (for HashRouter)
+ let urlParams = new URLSearchParams(window.location.search);
+ let debugKey = Array.from(urlParams.keys()).find(
(k) => k.toLowerCase() === "debug",
);
- return urlParams.get(debugKey)?.toLowerCase() === "true";
+
+ // If not found in search, try hash (for HashRouter with ?debug=true after hash)
+ if (!debugKey && window.location.hash.includes('?')) {
+ const hashSearch = window.location.hash.split('?')[1];
+ urlParams = new URLSearchParams(hashSearch);
+ debugKey = Array.from(urlParams.keys()).find(
+ (k) => k.toLowerCase() === "debug",
+ );
+ }
+
+ const isDebug = urlParams.get(debugKey)?.toLowerCase() === "true";
+ return isDebug;
});
const getCodeFromWorkspace = useRef(null);
const getCodeFromEditor = useRef(null);
+ // Rastrear atividade do jogo (analytics)
+ useGameActivityTracking(gameConfig, {
+ executionState,
+ currentPhase,
+ currentBlockCount,
+ editorType,
+ });
+
+ const trackGenericEvent = useCallback(
+ (eventName, extraData = {}) => {
+ if (!gameConfig?.gameId) return;
+
+ const analytics = getAnalytics();
+ analytics.trackEvent(eventName, {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ fase_numero: currentPhase,
+ editor_tipo: editorType,
+ blocos_atuais: currentBlockCount,
+ ...extraData,
+ });
+ },
+ [gameConfig?.gameId, gameConfig?.gameName, currentPhase, editorType, currentBlockCount],
+ );
+
/**
* Executa o código/workspace do editor de forma síncrona.
* Registra o código gerado e muda estado para EXECUTANDO.
@@ -103,6 +142,14 @@ function GameStateInnerProvider({ children, gameConfig }) {
* @throws {console.error} Se editor não registrou a função de execução
*/
const execute = () => {
+ trackGenericEvent("tentativa_execucao", {
+ origem_acao: "botao_executar",
+ });
+
+ trackGenericEvent("blocos_usados", {
+ origem_acao: "execucao",
+ });
+
if (editorType === "code") {
if (getCodeFromEditor.current) {
const codigo = getCodeFromEditor.current();
@@ -192,7 +239,15 @@ function GameStateInnerProvider({ children, gameConfig }) {
* @param {number} numeroFase - Número da fase para navegar (1-indexed)
* @returns {void}
*/
- const changePhase = (numeroFase) => {
+ const changePhase = (numeroFase, source = "unknown") => {
+ if (source === "manual_selector") {
+ trackGenericEvent("troca_fase_manual", {
+ fase_origem: currentPhase,
+ fase_destino: numeroFase,
+ origem_acao: source,
+ });
+ }
+
progressChangePhase(numeroFase);
setExecutionState(GAME_STATES.PARADO);
setGeneratedCode("");
diff --git a/app/src/contexts/GameStateContext.test.debug.js b/app/src/contexts/GameStateContext.test.debug.js
new file mode 100644
index 0000000..2ed22a0
--- /dev/null
+++ b/app/src/contexts/GameStateContext.test.debug.js
@@ -0,0 +1,35 @@
+/**
+ * Test to verify debug mode detection
+ * Run in browser console after accessing ?debug=true
+ */
+
+// Test URL parsing
+function testDebugDetection() {
+ console.log('=== DEBUG MODE DETECTION TEST ===');
+ console.log('window.location.href:', window.location.href);
+ console.log('window.location.search:', window.location.search);
+ console.log('window.location.hash:', window.location.hash);
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const debugKey = Array.from(urlParams.keys()).find(
+ (k) => k.toLowerCase() === "debug",
+ );
+
+ console.log('debugKey found:', debugKey);
+ console.log('debugKey value:', urlParams.get(debugKey));
+
+ const isDebug = urlParams.get(debugKey)?.toLowerCase() === "true";
+ console.log('isDebugMode:', isDebug);
+
+ // Also test if passing via hash works
+ const hashParams = new URLSearchParams(window.location.hash.split('?')[1]);
+ const debugKeyHash = Array.from(hashParams.keys()).find(
+ (k) => k.toLowerCase() === "debug",
+ );
+ console.log('\n--- Via Hash ---');
+ console.log('debugKeyHash found:', debugKeyHash);
+ console.log('debugKeyHash value:', hashParams.get(debugKeyHash));
+}
+
+// Run it
+testDebugDetection();
diff --git a/app/src/main.jsx b/app/src/main.jsx
index e291142..493a2db 100644
--- a/app/src/main.jsx
+++ b/app/src/main.jsx
@@ -8,6 +8,10 @@ import React from "react";
import ReactDOM from "react-dom/client";
import "./blockly/blocklyConfig.js"; // Configuração global do Blockly (locale PT-BR)
import App from "./App.jsx";
+import { initGoogleConsentMode } from "./services/analytics";
+
+// Initialize Google Consent Mode before loading analytics
+initGoogleConsentMode();
// Log de versão para rastreabilidade em produção
if (import.meta.env.PROD) {
diff --git a/app/src/pages/Faq/Faq.jsx b/app/src/pages/Faq/Faq.jsx
index 28d6475..ea4c51e 100644
--- a/app/src/pages/Faq/Faq.jsx
+++ b/app/src/pages/Faq/Faq.jsx
@@ -177,6 +177,19 @@ export default function Faq() {
))}
+
+
diff --git a/app/src/pages/HomePage/Footer.jsx b/app/src/pages/HomePage/Footer.jsx
index 0bc4d31..c83ce42 100644
--- a/app/src/pages/HomePage/Footer.jsx
+++ b/app/src/pages/HomePage/Footer.jsx
@@ -57,6 +57,14 @@ const Footer = () => {
FAQ
+
+
+ Privacidade
+
+
diff --git a/app/src/pages/PrivacyPolicy/PrivacyPolicy.jsx b/app/src/pages/PrivacyPolicy/PrivacyPolicy.jsx
new file mode 100644
index 0000000..e9e975b
--- /dev/null
+++ b/app/src/pages/PrivacyPolicy/PrivacyPolicy.jsx
@@ -0,0 +1,190 @@
+import Navbar from "../../components/Navbar";
+import Footer from "../HomePage/Footer";
+
+const privacyData = [
+ {
+ id: 1,
+ question: "Que dados o Decoda coleta?",
+ answer:
+ "O Decoda coleta dados anônimos sobre como você usa a plataforma para ajudar na melhoria contínua. Coletamos informações como quais páginas você visita, quanto tempo você passa em cada atividade, seu tipo de navegador e sistema operacional. Não coletamos dados pessoais como nome, email ou outras informações de identificação pessoal sem seu consentimento explícito.",
+ },
+ {
+ id: 2,
+ question: "Para que são usados esses dados?",
+ answer:
+ "Os dados coletados são usados exclusivamente para entender como os estudantes e educadores interagem com a plataforma, permitindo que melhoremos a usabilidade, corrijamos problemas técnicos e desenvolvamos novas funcionalidades mais alinhadas com as necessidades educacionais.",
+ },
+ {
+ id: 3,
+ question: "Como funciona o consentimento de cookies?",
+ answer:
+ "Respeitamos sua privacidade. Ao visitar o Decoda, você verá um aviso informando sobre a coleta de dados. Você pode aceitar ou rejeitar explicitamente. Se rejeitar, nenhum dado será coletado. Você pode mudar sua decisão a qualquer momento através das configurações de privacidade do site.",
+ },
+ {
+ id: 4,
+ question: "Meus dados estão seguros?",
+ answer:
+ "Sim. Todos os dados são transmitidos de forma criptografada (HTTPS) e são completamente anônimos. Nenhum usuário individual pode ser identificado pelos dados coletados. Os dados são agregados e processados com os mais altos padrões de segurança.",
+ },
+ {
+ id: 5,
+ question: "O que é armazenado localmente no meu navegador?",
+ answer:
+ "O Decoda armazena seu progresso localmente no seu navegador (dados como quais atividades você completou, seu código blocos salvos). Esses dados são seus e permanecem no seu dispositivo. Nenhum desses dados pessoais de progresso é enviado para terceiros.",
+ },
+ {
+ id: 6,
+ question: "Como funciona o Decoda offline?",
+ answer:
+ "O Decoda pode funcionar completamente offline através da versão PWA (Progressive Web App) ou desktop (Electron). Quando offline, nenhum dado é coletado ou enviado. O rastreamento de uso só ocorre quando você está conectado à internet.",
+ },
+ {
+ id: 7,
+ question: "Como optar por não ser rastreado?",
+ answer:
+ "Você tem várias opções para optar por não ser rastreado:",
+ list: [
+ "Rejeitar cookies no aviso ao entrar no site",
+ "Usar o navegador em modo incógnito/privado",
+ "Desabilitar 'Do Not Track' (DNT) nas configurações do seu navegador",
+ "Limpar cookies e dados do navegador regularmente",
+ ],
+ },
+ {
+ id: 8,
+ question: "Há publicidade no Decoda?",
+ answer:
+ "Não. O Decoda não possui publicidade. A plataforma é financiada para servir educadores e estudantes de forma gratuita, sem modelos baseados em publicidade ou venda de dados.",
+ },
+ {
+ id: 9,
+ question: "É preciso criar uma conta?",
+ answer:
+ "Não é necessário. O Decoda funciona sem cadastro ou login. Todo seu progresso é salvo localmente no seu navegador, mantendo a máxima privacidade e simplicidade.",
+ },
+ {
+ id: 10,
+ question: "Com quanto tempo os dados são deletados?",
+ answer:
+ "Os dados agregados são mantidos por um período padrão para análise de tendências. Você pode limpar todos os dados em qualquer momento nas configurações de privacidade do seu navegador ou através do aviso de cookies no Decoda.",
+ },
+ {
+ id: 11,
+ question: "Posso compartilhar meus dados de atividade?",
+ answer:
+ "Seu progresso é mantido privado no seu dispositivo. Se você deseja compartilhar, você pode exportar ou descrever manualmente seus resultados. Nenhum dado é compartilhado automaticamente sem seu consentimento explícito.",
+ },
+ {
+ id: 12,
+ question: "Alterações nesta Política",
+ answer:
+ "Podemos atualizar esta Política de Privacidade ocasionalmente para refletir melhorias na plataforma ou mudanças legais. Recomendamos revisar esta página periodicamente para estar informado sobre qualquer mudança.",
+ },
+];
+
+export function PrivacyPolicy() {
+ return (
+ <>
+ {/* Navegação */}
+
+
+
+ {/* Container principal */}
+
+
+
+ Política de Privacidade
+
+
+
+ O Decoda é compromissado com a privacidade e transparência. Esta página
+ explica como coletamos e usamos dados, e como você pode controlar suas
+ preferências de privacidade.
+
+
+
+ {privacyData.map((item, index) => (
+
+
+
+
+ {item.question}
+
+
+
+
+
+
+
+
+
+
+
+
+ {item.answer}
+
+ {item.list && (
+
+ {item.list.map((listItem, idx) => (
+ {listItem}
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+
+ Última atualização: {new Date().toLocaleDateString('pt-BR')}
+
+
+ Se você tiver dúvidas sobre nossa Política de Privacidade, entre em contato conosco.
+
+
+
+
+
+
+ {/* Footer */}
+
+ >
+ );
+}
diff --git a/app/src/services/analytics/AnalyticsManager.js b/app/src/services/analytics/AnalyticsManager.js
new file mode 100644
index 0000000..323bc96
--- /dev/null
+++ b/app/src/services/analytics/AnalyticsManager.js
@@ -0,0 +1,96 @@
+import { GA4Provider } from './providers/GA4Provider';
+import { NoopProvider } from './providers/NoopProvider';
+import { networkDetector } from './NetworkDetector';
+import { updateGoogleConsent } from './googleConsentMode';
+
+export class AnalyticsManager {
+ constructor(config = {}) {
+ this.provider = this.createProvider(config);
+ this.networkDetector = networkDetector;
+ this.config = config;
+ this.consentLocked = false;
+ }
+
+ createProvider(config) {
+ const providerType = config.providerType || 'noop';
+
+ switch (providerType) {
+ case 'ga4':
+ return new GA4Provider({
+ measurementId: config.measurementId,
+ hasConsent: config.hasConsent,
+ debugMode: config.debugMode,
+ });
+ case 'noop':
+ default:
+ return new NoopProvider();
+ }
+ }
+
+ async initialize() {
+ if (!this.networkDetector.getStatus()) {
+ console.log('AnalyticsManager: offline, skipping initialization');
+ return;
+ }
+
+ await this.provider.initialize();
+ }
+
+ trackPageView(data) {
+ if (!this.networkDetector.getStatus()) return;
+ this.provider.trackPageView(data);
+ }
+
+ trackEvent(eventName, eventData) {
+ if (!this.networkDetector.getStatus()) return;
+
+ this.provider.trackEvent(eventName, eventData);
+ }
+
+ setUserId(userId) {
+ this.provider.setUserId(userId);
+ }
+
+ setConsentGranted(hasConsent) {
+ updateGoogleConsent(hasConsent);
+
+ if (this.provider.setConsentGranted) {
+ this.provider.setConsentGranted(hasConsent);
+ }
+ }
+
+ _setConsentGrantedInternal(hasConsent) {
+ this.setConsentGranted(hasConsent);
+ this.consentLocked = true;
+ if (this.provider._lockConsent) {
+ this.provider._lockConsent();
+ }
+ }
+}
+
+let instance = null;
+
+export function initializeAnalytics(config) {
+ instance = new AnalyticsManager(config);
+ // Initialize provider (load GA4 script, etc)
+ instance.initialize().catch(err => {
+ console.error('❌ Failed to initialize analytics:', err);
+ });
+ // Expose globally for testing only in development
+ if (import.meta.env.DEV) {
+ window.__ANALYTICS__ = instance;
+ window.__getAnalytics__ = getAnalytics;
+ }
+ return instance;
+}
+
+export function getAnalytics() {
+ if (!instance) {
+ console.warn('⚠️ Analytics not initialized, creating NoopProvider');
+ instance = new AnalyticsManager({ providerType: 'noop' });
+ if (import.meta.env.DEV) {
+ window.__ANALYTICS__ = instance;
+ }
+ }
+ return instance;
+}
diff --git a/app/src/services/analytics/EventBatcher.js b/app/src/services/analytics/EventBatcher.js
new file mode 100644
index 0000000..e8bcd0a
--- /dev/null
+++ b/app/src/services/analytics/EventBatcher.js
@@ -0,0 +1,62 @@
+/**
+ * EventBatcher - Acumula eventos e os envia em lotes
+ * Reduz latência e consumo de banda, melhorando performance em conexões lentas
+ */
+export class EventBatcher {
+ constructor(config = {}) {
+ this.batchSize = config.batchSize || 10; // Envia a cada 10 eventos
+ this.batchTimeout = config.batchTimeout || 30000; // Ou a cada 30 segundos
+ this.onBatch = config.onBatch || (() => {}); // Callback quando lote está pronto
+
+ this.queue = [];
+ this.timeoutId = null;
+ }
+
+ add(event) {
+ if (!event) return;
+
+ this.queue.push({
+ ...event,
+ timestamp: new Date().toISOString(),
+ });
+
+ // Se atingiu tamanho do lote, enviar imediatamente
+ if (this.queue.length >= this.batchSize) {
+ this.flush();
+ } else if (!this.timeoutId) {
+ // Se não tem timer, iniciar
+ this.timeoutId = setTimeout(() => this.flush(), this.batchTimeout);
+ }
+ }
+
+ flush() {
+ if (this.queue.length === 0) {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+ return;
+ }
+
+ const batch = [...this.queue];
+
+ this.queue = [];
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+
+ this.onBatch(batch);
+ }
+
+ getPendingCount() {
+ return this.queue.length;
+ }
+
+ destroy() {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ }
+ this.queue = [];
+ }
+}
diff --git a/app/src/services/analytics/NetworkDetector.js b/app/src/services/analytics/NetworkDetector.js
new file mode 100644
index 0000000..8160b5d
--- /dev/null
+++ b/app/src/services/analytics/NetworkDetector.js
@@ -0,0 +1,30 @@
+export class NetworkDetector {
+ constructor() {
+ this.isOnline = navigator.onLine;
+ this.listeners = [];
+ }
+
+ subscribe(listener) {
+ this.listeners.push(listener);
+
+ window.addEventListener('online', () => {
+ this.isOnline = true;
+ this.listeners.forEach(l => l(true));
+ });
+
+ window.addEventListener('offline', () => {
+ this.isOnline = false;
+ this.listeners.forEach(l => l(false));
+ });
+
+ return () => {
+ this.listeners = this.listeners.filter(l => l !== listener);
+ };
+ }
+
+ getStatus() {
+ return this.isOnline;
+ }
+}
+
+export const networkDetector = new NetworkDetector();
diff --git a/app/src/services/analytics/analytics.test.js b/app/src/services/analytics/analytics.test.js
new file mode 100644
index 0000000..86d3329
--- /dev/null
+++ b/app/src/services/analytics/analytics.test.js
@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest';
+import { EventBatcher } from './EventBatcher';
+import { ConsentManager } from '../consent/ConsentManager';
+import { AnalyticsManager } from './AnalyticsManager';
+
+describe('Analytics System', () => {
+ describe('EventBatcher', () => {
+ it('should create batcher with default config', () => {
+ const batcher = new EventBatcher();
+ expect(batcher.batchSize).toBe(10);
+ expect(batcher.batchTimeout).toBe(30000);
+ });
+
+ it('should create batcher with custom config', () => {
+ const batcher = new EventBatcher({
+ batchSize: 5,
+ batchTimeout: 5000,
+ });
+ expect(batcher.batchSize).toBe(5);
+ expect(batcher.batchTimeout).toBe(5000);
+ });
+
+ it('should track pending count', () => {
+ const batcher = new EventBatcher();
+ expect(batcher.getPendingCount()).toBe(0);
+
+ batcher.add({ eventName: 'test' });
+ expect(batcher.getPendingCount()).toBe(1);
+
+ batcher.destroy();
+ });
+
+ it('should not add null events', () => {
+ const batcher = new EventBatcher();
+ batcher.add(null);
+ batcher.add(undefined);
+ expect(batcher.getPendingCount()).toBe(0);
+ });
+ });
+
+ describe('ConsentManager', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('should manage consent state', () => {
+ ConsentManager.setConsent(true);
+ expect(ConsentManager.hasConsent()).toBe(true);
+ expect(ConsentManager.hasUserDecided()).toBe(true);
+ });
+
+ it('should return false when consent not set', () => {
+ expect(ConsentManager.hasConsent()).toBe(false);
+ expect(ConsentManager.hasUserDecided()).toBe(false);
+ });
+
+ it('should reset consent', () => {
+ ConsentManager.setConsent(true);
+ ConsentManager.reset();
+ expect(ConsentManager.hasUserDecided()).toBe(false);
+ });
+
+ it('should store and retrieve consent', () => {
+ ConsentManager.setConsent(true);
+ const consent = ConsentManager.getConsent();
+ expect(consent.accepted).toBe(true);
+ expect(consent.version).toBe('1');
+ });
+ });
+
+ describe('AnalyticsManager', () => {
+ it('should create with noop provider by default', () => {
+ const manager = new AnalyticsManager();
+ expect(manager.provider.constructor.name).toBe('NoopProvider');
+ });
+
+ it('should create with GA4Provider', () => {
+ const manager = new AnalyticsManager({
+ providerType: 'ga4',
+ measurementId: 'G-TEST123',
+ });
+ expect(manager.provider.constructor.name).toBe('GA4Provider');
+ });
+
+ it('should fallback to noop for unknown provider', () => {
+ const manager = new AnalyticsManager({
+ providerType: 'unknown',
+ });
+ expect(manager.provider.constructor.name).toBe('NoopProvider');
+ });
+
+ it('should have network detector', () => {
+ const manager = new AnalyticsManager();
+ expect(manager.networkDetector).toBeDefined();
+ expect(typeof manager.networkDetector.getStatus).toBe('function');
+ });
+ });
+});
diff --git a/app/src/services/analytics/config.js b/app/src/services/analytics/config.js
new file mode 100644
index 0000000..2d05d04
--- /dev/null
+++ b/app/src/services/analytics/config.js
@@ -0,0 +1,12 @@
+const envDebugMode = import.meta.env.VITE_GA4_DEBUG;
+
+export const analyticsConfig = {
+ providerType: import.meta.env.VITE_ANALYTICS_PROVIDER || 'noop',
+ measurementId: import.meta.env.VITE_GA4_ID,
+ debugMode:
+ envDebugMode === 'true'
+ ? true
+ : envDebugMode === 'false'
+ ? false
+ : import.meta.env.DEV,
+};
diff --git a/app/src/services/analytics/googleConsentMode.js b/app/src/services/analytics/googleConsentMode.js
new file mode 100644
index 0000000..75c06f8
--- /dev/null
+++ b/app/src/services/analytics/googleConsentMode.js
@@ -0,0 +1,53 @@
+export function initGoogleConsentMode() {
+ const CONSENT_KEY = 'decoda_consent';
+
+ try {
+ const stored = localStorage.getItem(CONSENT_KEY);
+ let hasConsent = false;
+
+ if (stored) {
+ try {
+ const data = JSON.parse(stored);
+ hasConsent = data.accepted === true;
+ } catch {
+ hasConsent = false;
+ }
+ }
+
+ window.dataLayer = window.dataLayer || [];
+
+ function gtag() {
+ window.dataLayer.push(arguments);
+ }
+
+ gtag('consent', 'default', {
+ ad_personalization: hasConsent ? 'granted' : 'denied',
+ ad_storage: hasConsent ? 'granted' : 'denied',
+ ad_user_data: hasConsent ? 'granted' : 'denied',
+ ad_user_id: hasConsent ? 'granted' : 'denied',
+ analytics_storage: hasConsent ? 'granted' : 'denied',
+ functionality_storage: hasConsent ? 'granted' : 'denied',
+ personalization_storage: hasConsent ? 'granted' : 'denied',
+ security_storage: 'granted',
+ });
+
+ return gtag;
+ } catch (error) {
+ console.error('Error initializing Google Consent Mode:', error);
+ return null;
+ }
+}
+
+export function updateGoogleConsent(hasConsent) {
+ window.gtag = window.gtag || (() => {});
+
+ window.gtag('consent', 'update', {
+ ad_personalization: hasConsent ? 'granted' : 'denied',
+ ad_storage: hasConsent ? 'granted' : 'denied',
+ ad_user_data: hasConsent ? 'granted' : 'denied',
+ ad_user_id: hasConsent ? 'granted' : 'denied',
+ analytics_storage: hasConsent ? 'granted' : 'denied',
+ functionality_storage: hasConsent ? 'granted' : 'denied',
+ personalization_storage: hasConsent ? 'granted' : 'denied',
+ });
+}
diff --git a/app/src/services/analytics/index.js b/app/src/services/analytics/index.js
new file mode 100644
index 0000000..4727d14
--- /dev/null
+++ b/app/src/services/analytics/index.js
@@ -0,0 +1,8 @@
+export { initializeAnalytics, getAnalytics } from './AnalyticsManager';
+export { usePageTracking } from './usePageTracking';
+export { useActivityTracking } from './useActivityTracking';
+export { useLetramentoTracking } from './useLetramentoTracking';
+export { useGameActivityTracking } from './useGameActivityTracking';
+export { initGoogleConsentMode, updateGoogleConsent } from './googleConsentMode';
+export { networkDetector } from './NetworkDetector';
+export { analyticsConfig } from './config';
diff --git a/app/src/services/analytics/providers/BaseProvider.js b/app/src/services/analytics/providers/BaseProvider.js
new file mode 100644
index 0000000..9025ad6
--- /dev/null
+++ b/app/src/services/analytics/providers/BaseProvider.js
@@ -0,0 +1,29 @@
+export class BaseProvider {
+ constructor(config = {}) {
+ this.config = config;
+ this.enabled = false;
+ }
+
+ async initialize() {
+ throw new Error('initialize() must be implemented by subclass');
+ }
+
+ trackPageView(data) {
+ if (!this.enabled) return;
+ throw new Error('trackPageView() must be implemented by subclass');
+ }
+
+ trackEvent(eventName, eventData) {
+ if (!this.enabled) return;
+ throw new Error('trackEvent() must be implemented by subclass');
+ }
+
+ setUserId(userId) {
+ if (!this.enabled) return;
+ throw new Error('setUserId() must be implemented by subclass');
+ }
+
+ destroy() {
+ // Optional cleanup, subclasses can override
+ }
+}
diff --git a/app/src/services/analytics/providers/GA4Provider.js b/app/src/services/analytics/providers/GA4Provider.js
new file mode 100644
index 0000000..4b99846
--- /dev/null
+++ b/app/src/services/analytics/providers/GA4Provider.js
@@ -0,0 +1,169 @@
+import { BaseProvider } from './BaseProvider';
+import { updateGoogleConsent } from '../googleConsentMode';
+import { EventBatcher } from '../EventBatcher';
+
+export class GA4Provider extends BaseProvider {
+ constructor(config = {}) {
+ super(config);
+ this.measurementId = config.measurementId;
+ this.hasConsent = config.hasConsent || false;
+ this.consentLocked = false;
+ const envDebugMode = import.meta.env.VITE_GA4_DEBUG;
+ this.debugMode =
+ typeof config.debugMode === 'boolean'
+ ? config.debugMode
+ : envDebugMode === 'true'
+ ? true
+ : envDebugMode === 'false'
+ ? false
+ : import.meta.env.DEV;
+
+ // Batching configuration
+ this.batcher = new EventBatcher({
+ batchSize: config.batchSize || 10,
+ batchTimeout: config.batchTimeout || 30000,
+ onBatch: (batch) => this.sendBatch(batch),
+ });
+ }
+
+ async initialize() {
+ if (!this.measurementId) {
+ console.warn('GA4Provider: measurementId not provided');
+ return;
+ }
+
+ if (!this.hasConsent) {
+ return;
+ }
+
+ this.loadGA4Script();
+ this.enabled = true;
+ }
+
+ loadGA4Script() {
+ // Initialize dataLayer and gtag function
+ window.dataLayer = window.dataLayer || [];
+
+ window.gtag = function () {
+ window.dataLayer.push(arguments);
+ };
+
+ // Configure gtag
+ window.gtag('js', new Date());
+
+ window.gtag('config', this.measurementId, {
+ page_path: window.location.pathname,
+ anonymize_ip: true,
+ allow_google_signals: false,
+ debug_mode: this.debugMode,
+ });
+
+ const scriptSrc = `https://www.googletagmanager.com/gtag/js?id=${this.measurementId}`;
+ const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
+ if (existingScript) {
+ return;
+ }
+
+ // Load GA4 script
+ const script = document.createElement('script');
+ script.async = true;
+ script.src = scriptSrc;
+
+ script.onload = () => {};
+
+ script.onerror = () => {
+ console.error('❌ Failed to load GA4 script from Google');
+ };
+
+ document.head.appendChild(script);
+ }
+
+ sendBatch(batch) {
+ if (!this.enabled) {
+ console.warn('GA4Provider: cannot send batch, not enabled');
+ return;
+ }
+
+ if (!window.gtag) {
+ console.error('❌ GA4Provider: window.gtag is NOT available!');
+ console.error('window.gtag type:', typeof window.gtag);
+ console.error('window.dataLayer:', window.dataLayer);
+ return;
+ }
+
+ batch.forEach((event) => {
+ try {
+ const eventData = {
+ ...event.data,
+ _timestamp: event.timestamp,
+ ...(this.debugMode ? { debug_mode: true } : {}),
+ };
+ window.gtag('event', event.eventName, eventData);
+ } catch (err) {
+ console.error('❌ Error sending event to GA4:', err);
+ }
+ });
+ }
+
+ trackPageView(data) {
+ if (!this.enabled) {
+ console.warn('GA4Provider: not enabled, skipping pageview');
+ return;
+ }
+
+ this.batcher.add({
+ eventName: 'page_view',
+ data: {
+ page_title: data.title || document.title,
+ page_path: data.path || window.location.pathname,
+ page_location: data.location || window.location.href,
+ },
+ });
+ }
+
+ trackEvent(eventName, eventData = {}) {
+ if (!this.enabled) {
+ console.warn('GA4Provider: not enabled, skipping event:', eventName);
+ return;
+ }
+
+ this.batcher.add({
+ eventName,
+ data: eventData,
+ });
+ }
+
+ setUserId(userId) {
+ if (!this.enabled || !window.gtag) return;
+
+ window.gtag('config', this.measurementId, {
+ user_id: userId,
+ });
+ }
+
+ setConsentGranted(hasConsent) {
+ if (this.consentLocked) {
+ console.warn('GA4Provider: Consent decision is locked and cannot be changed');
+ return;
+ }
+ this.hasConsent = hasConsent;
+ updateGoogleConsent(hasConsent);
+
+ if (hasConsent && !this.enabled) {
+ this.loadGA4Script();
+ this.enabled = true;
+ }
+ }
+
+ _lockConsent() {
+ this.consentLocked = true;
+ }
+
+ flush() {
+ this.batcher.flush();
+ }
+
+ destroy() {
+ this.batcher.destroy();
+ }
+}
diff --git a/app/src/services/analytics/providers/NoopProvider.js b/app/src/services/analytics/providers/NoopProvider.js
new file mode 100644
index 0000000..055dbf2
--- /dev/null
+++ b/app/src/services/analytics/providers/NoopProvider.js
@@ -0,0 +1,23 @@
+import { BaseProvider } from './BaseProvider';
+
+export class NoopProvider extends BaseProvider {
+ async initialize() {
+ console.log('NoopProvider initialized (analytics disabled)');
+ }
+
+ trackPageView(data) {
+ // No-op
+ }
+
+ trackEvent(eventName, eventData) {
+ // No-op
+ }
+
+ setUserId(userId) {
+ // No-op
+ }
+
+ destroy() {
+ // No-op
+ }
+}
diff --git a/app/src/services/analytics/useActivityTracking.js b/app/src/services/analytics/useActivityTracking.js
new file mode 100644
index 0000000..b2b2cf6
--- /dev/null
+++ b/app/src/services/analytics/useActivityTracking.js
@@ -0,0 +1,74 @@
+import { useEffect, useRef } from 'react';
+import { getAnalytics } from './AnalyticsManager';
+
+export function useActivityTracking(gameConfig) {
+ const analyticsRef = useRef(getAnalytics());
+ const sessionStartRef = useRef(Date.now());
+ const trackedEventsRef = useRef(new Set());
+
+ // Track activity start
+ useEffect(() => {
+ if (!gameConfig?.gameId) return;
+
+ sessionStartRef.current = Date.now();
+ analyticsRef.current.trackEvent('atividade_iniciada', {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ categoria: gameConfig.categoria || 'programacao',
+ dificuldade: gameConfig.dificuldade || 'desconhecida',
+ total_fases: gameConfig.fases?.length || 0,
+ conceitos: gameConfig.conceitos?.join(',') || '',
+ });
+ }, [gameConfig?.gameId, gameConfig?.gameName, gameConfig?.categoria, gameConfig?.dificuldade, gameConfig?.fases?.length, gameConfig?.conceitos]);
+
+ // Track phase completion
+ const trackPhaseCompletion = (phaseId, phaseName, success) => {
+ if (!gameConfig?.gameId) return;
+
+ const eventKey = `phase-${phaseId}-${success}`;
+ if (trackedEventsRef.current.has(eventKey)) return;
+ trackedEventsRef.current.add(eventKey);
+
+ const sessionDuration = Math.round((Date.now() - sessionStartRef.current) / 1000);
+
+ analyticsRef.current.trackEvent(
+ success ? 'fase_completada' : 'fase_falhou',
+ {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ fase_numero: phaseId,
+ fase_nome: phaseName || `Fase ${phaseId}`,
+ tempo_sessao_segundos: sessionDuration,
+ categoria: gameConfig.categoria || 'programacao',
+ }
+ );
+ };
+
+ // Track activity abandonment
+ const trackActivityAbandonment = () => {
+ if (!gameConfig?.gameId) return;
+
+ analyticsRef.current.trackEvent('atividade_abandonada', {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ tempo_sessao_segundos: Math.round((Date.now() - sessionStartRef.current) / 1000),
+ });
+ };
+
+ // Track custom activity event
+ const trackActivityEvent = (eventName, eventData = {}) => {
+ if (!gameConfig?.gameId) return;
+
+ analyticsRef.current.trackEvent(eventName, {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ ...eventData,
+ });
+ };
+
+ return {
+ trackPhaseCompletion,
+ trackActivityAbandonment,
+ trackActivityEvent,
+ };
+}
diff --git a/app/src/services/analytics/useGameActivityTracking.js b/app/src/services/analytics/useGameActivityTracking.js
new file mode 100644
index 0000000..b50dc68
--- /dev/null
+++ b/app/src/services/analytics/useGameActivityTracking.js
@@ -0,0 +1,126 @@
+import { useEffect, useRef } from 'react';
+import { getAnalytics } from './AnalyticsManager';
+
+/**
+ * Hook para rastrear atividades de jogos com estado (GameStateContext)
+ * Rastreia: acesso, fases, sucesso/falha, seleção, tempo
+ */
+export function useGameActivityTracking(gameConfig, gameState) {
+ const analyticsRef = useRef(getAnalytics());
+ const sessionStartRef = useRef(Date.now());
+ const phaseStartRef = useRef(Date.now());
+ const trackedEventsRef = useRef(new Set());
+ const firstSuccessTrackedRef = useRef(false);
+
+ const getCommonEventData = () => ({
+ editor_tipo: gameState?.editorType || 'blockly',
+ blocos_atuais: gameState?.currentBlockCount || 0,
+ });
+
+ // Track activity start
+ useEffect(() => {
+ if (!gameConfig?.gameId) return;
+
+ sessionStartRef.current = Date.now();
+
+ analyticsRef.current.trackEvent('atividade_acessada', {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ categoria: gameConfig.categoria || 'programacao',
+ dificuldade: gameConfig.dificuldade || 'desconhecida',
+ total_fases: gameConfig.fases?.length || 0,
+ ...getCommonEventData(),
+ });
+ }, [gameConfig?.gameId]);
+
+ // Track phase change
+ useEffect(() => {
+ if (!gameConfig?.gameId || !gameState?.currentPhase) return;
+
+ phaseStartRef.current = Date.now();
+ const phase = gameConfig.fases[gameState.currentPhase - 1];
+
+ analyticsRef.current.trackEvent('fase_selecionada', {
+ atividade_id: gameConfig.gameId,
+ fase_numero: gameState.currentPhase,
+ fase_nome: phase?.nome || `Fase ${gameState.currentPhase}`,
+ ...getCommonEventData(),
+ });
+ }, [gameState?.currentPhase, gameConfig?.gameId]);
+
+ // Track phase success/failure
+ useEffect(() => {
+ if (!gameConfig?.gameId || !gameState) return;
+
+ const { executionState, currentPhase } = gameState;
+ const phase = gameConfig.fases[currentPhase - 1];
+
+ // Success
+ if (executionState === 'sucesso') {
+ const eventKey = `success-${currentPhase}`;
+ if (!trackedEventsRef.current.has(eventKey)) {
+ trackedEventsRef.current.add(eventKey);
+
+ const phaseTime = Math.round((Date.now() - phaseStartRef.current) / 1000);
+ const sessionTime = Math.round((Date.now() - sessionStartRef.current) / 1000);
+
+ analyticsRef.current.trackEvent('fase_completada', {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ fase_numero: currentPhase,
+ fase_nome: phase?.nome || `Fase ${currentPhase}`,
+ tempo_fase_segundos: phaseTime,
+ tempo_sessao_segundos: sessionTime,
+ categoria: gameConfig.categoria || 'programacao',
+ ...getCommonEventData(),
+ });
+
+ if (!firstSuccessTrackedRef.current) {
+ firstSuccessTrackedRef.current = true;
+ analyticsRef.current.trackEvent('tempo_ate_primeiro_sucesso', {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ fase_numero: currentPhase,
+ tempo_sessao_segundos: sessionTime,
+ categoria: gameConfig.categoria || 'programacao',
+ ...getCommonEventData(),
+ });
+ }
+ }
+ }
+
+ // Failure
+ if (executionState === 'falha') {
+ const eventKey = `failure-${currentPhase}`;
+ if (!trackedEventsRef.current.has(eventKey)) {
+ trackedEventsRef.current.add(eventKey);
+
+ const phaseTime = Math.round((Date.now() - phaseStartRef.current) / 1000);
+
+ analyticsRef.current.trackEvent('fase_falhou', {
+ atividade_id: gameConfig.gameId,
+ fase_numero: currentPhase,
+ fase_nome: phase?.nome || `Fase ${currentPhase}`,
+ tempo_fase_segundos: phaseTime,
+ ...getCommonEventData(),
+ });
+ }
+ }
+ }, [gameState?.executionState, gameState?.currentPhase, gameConfig?.gameId]);
+
+ // Track activity abandonment on unmount
+ useEffect(() => {
+ return () => {
+ if (gameConfig?.gameId) {
+ const sessionTime = Math.round((Date.now() - sessionStartRef.current) / 1000);
+
+ analyticsRef.current.trackEvent('atividade_abandonada', {
+ atividade_id: gameConfig.gameId,
+ atividade_nome: gameConfig.gameName || gameConfig.gameId,
+ tempo_sessao_segundos: sessionTime,
+ ...getCommonEventData(),
+ });
+ }
+ };
+ }, [gameConfig?.gameId]);
+}
diff --git a/app/src/services/analytics/useLetramentoTracking.js b/app/src/services/analytics/useLetramentoTracking.js
new file mode 100644
index 0000000..634938e
--- /dev/null
+++ b/app/src/services/analytics/useLetramentoTracking.js
@@ -0,0 +1,68 @@
+import { useEffect, useRef } from 'react';
+import { getAnalytics } from './AnalyticsManager';
+
+/**
+ * Hook para rastrear uso de atividades de letramento
+ * @param {string} activityId - ID único da atividade de letramento (ex: 'mouse-basico')
+ * @param {string} categoryName - Nome da categoria (ex: 'Mouse', 'Teclado')
+ */
+export function useLetramentoTracking(activityId, categoryName) {
+ const analyticsRef = useRef(getAnalytics());
+ const sessionStartRef = useRef(Date.now());
+ const completionTrackedRef = useRef(false);
+
+ // Track activity start
+ useEffect(() => {
+ if (!activityId) return;
+
+ sessionStartRef.current = Date.now();
+ analyticsRef.current.trackEvent('letramento_atividade_iniciada', {
+ atividade_id: activityId,
+ categoria: categoryName || 'letramento',
+ });
+
+ return () => {
+ // Track abandonment on unmount if not completed
+ if (!completionTrackedRef.current) {
+ analyticsRef.current.trackEvent('letramento_atividade_abandonada', {
+ atividade_id: activityId,
+ categoria: categoryName || 'letramento',
+ tempo_sessao_segundos: Math.round((Date.now() - sessionStartRef.current) / 1000),
+ });
+ }
+ };
+ }, [activityId, categoryName]);
+
+ // Track activity completion
+ const trackCompletion = (success = true) => {
+ if (!activityId || completionTrackedRef.current) return;
+ completionTrackedRef.current = true;
+
+ const sessionDuration = Math.round((Date.now() - sessionStartRef.current) / 1000);
+
+ analyticsRef.current.trackEvent(
+ success ? 'letramento_atividade_completada' : 'letramento_atividade_falhou',
+ {
+ atividade_id: activityId,
+ categoria: categoryName || 'letramento',
+ tempo_sessao_segundos: sessionDuration,
+ }
+ );
+ };
+
+ // Track custom letramento event
+ const trackEvent = (eventName, eventData = {}) => {
+ if (!activityId) return;
+
+ analyticsRef.current.trackEvent(eventName, {
+ atividade_id: activityId,
+ categoria: categoryName || 'letramento',
+ ...eventData,
+ });
+ };
+
+ return {
+ trackCompletion,
+ trackEvent,
+ };
+}
diff --git a/app/src/services/analytics/usePageTracking.js b/app/src/services/analytics/usePageTracking.js
new file mode 100644
index 0000000..a9531cf
--- /dev/null
+++ b/app/src/services/analytics/usePageTracking.js
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import { getAnalytics } from './AnalyticsManager';
+
+export function usePageTracking(enabled = true) {
+ const location = useLocation();
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const analytics = getAnalytics();
+ analytics.trackPageView({
+ path: location.pathname,
+ title: document.title,
+ });
+ }, [enabled, location.pathname]);
+}
diff --git a/app/src/services/consent/ConsentManager.js b/app/src/services/consent/ConsentManager.js
new file mode 100644
index 0000000..3f31d11
--- /dev/null
+++ b/app/src/services/consent/ConsentManager.js
@@ -0,0 +1,38 @@
+const CONSENT_KEY = 'decoda_consent';
+const CONSENT_VERSION = '1';
+
+export const ConsentManager = {
+ getConsent() {
+ const stored = localStorage.getItem(CONSENT_KEY);
+ if (!stored) return null;
+
+ try {
+ const data = JSON.parse(stored);
+ return data.version === CONSENT_VERSION ? data : null;
+ } catch {
+ return null;
+ }
+ },
+
+ setConsent(accepted) {
+ const consent = {
+ version: CONSENT_VERSION,
+ accepted,
+ timestamp: new Date().toISOString(),
+ };
+ localStorage.setItem(CONSENT_KEY, JSON.stringify(consent));
+ },
+
+ hasConsent() {
+ const consent = this.getConsent();
+ return consent ? consent.accepted : false;
+ },
+
+ hasUserDecided() {
+ return this.getConsent() !== null;
+ },
+
+ reset() {
+ localStorage.removeItem(CONSENT_KEY);
+ },
+};
diff --git a/app/src/services/consent/index.js b/app/src/services/consent/index.js
new file mode 100644
index 0000000..2178031
--- /dev/null
+++ b/app/src/services/consent/index.js
@@ -0,0 +1,2 @@
+export { ConsentManager } from './ConsentManager';
+export { useConsent } from './useConsent';
diff --git a/app/src/services/consent/useConsent.js b/app/src/services/consent/useConsent.js
new file mode 100644
index 0000000..c7536f6
--- /dev/null
+++ b/app/src/services/consent/useConsent.js
@@ -0,0 +1,34 @@
+import { useState, useEffect } from 'react';
+import { ConsentManager } from './ConsentManager';
+
+export function useConsent() {
+ const [consent, setConsent] = useState(null);
+ const [hasDecided, setHasDecided] = useState(false);
+
+ useEffect(() => {
+ setConsent(ConsentManager.getConsent());
+ setHasDecided(ConsentManager.hasUserDecided());
+ }, []);
+
+ const acceptConsent = () => {
+ ConsentManager.setConsent(true);
+ setConsent(ConsentManager.getConsent());
+ setHasDecided(true);
+ };
+
+ const rejectConsent = () => {
+ ConsentManager.setConsent(false);
+ setConsent(ConsentManager.getConsent());
+ setHasDecided(true);
+ };
+
+ const hasConsent = ConsentManager.hasConsent();
+
+ return {
+ consent,
+ hasConsent,
+ hasDecided,
+ acceptConsent,
+ rejectConsent,
+ };
+}
diff --git a/app/stats.html b/app/stats.html
index 3d11385..8db0dd4 100644
--- a/app/stats.html
+++ b/app/stats.html
@@ -4929,7 +4929,7 @@ var drawChart = (function (exports) {