add analytics

This commit is contained in:
2026-06-05 00:11:45 -03:00
parent a2947b3bf2
commit fd8e9049bf
35 changed files with 1540 additions and 30 deletions

3
app/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_ANALYTICS_PROVIDER=ga4
VITE_GA4_ID=
VITE_GA4_DEBUG=false

View File

@@ -3,9 +3,17 @@ 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
ENV VITE_ANALYTICS_PROVIDER=$VITE_ANALYTICS_PROVIDER
ENV VITE_GA4_ID=$VITE_GA4_ID
ENV VITE_GA4_DEBUG=$VITE_GA4_DEBUG
RUN npm install -g pnpm

View File

@@ -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",

View File

@@ -4,12 +4,106 @@
* @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";
import { ConsentManager } from "./services/consent";
// Inline CookieBanner para evitar bloqueio do Brave
function CookieBanner() {
const [visible, setVisible] = useState(false);
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(),
}));
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) return null;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 md:p-6" role="dialog" aria-label="Consentimento de cookies">
<div className="mx-auto max-w-4xl">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-gray-200">
<div className="h-1 bg-gradient-to-r from-red-600 to-pink-600"></div>
<div className="p-6 md:p-8">
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start">
<div className="flex-1 min-w-0">
<h2 className="font-title font-bold text-xl md:text-2xl text-gray-900 mb-2">
Sua privacidade é importante
</h2>
<p className="font-sans text-sm md:text-base text-gray-700 leading-relaxed">
Utilizamos dados sobre como você usa a plataforma para melhorar a experiência educacional
e entender quais recursos são mais úteis.{' '}
<a
href="#/privacy-policy"
className="text-red-600 hover:text-red-700 font-semibold underline transition-colors"
>
Saiba mais
</a>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full md:w-auto flex-shrink-0">
<button
onClick={handleReject}
className="px-6 py-2.5 rounded-xl font-sans font-semibold text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 transition-all duration-200 border border-gray-300"
aria-label="Rejeitar"
>
Rejeitar
</button>
<button
onClick={handleAccept}
className="px-6 py-2.5 rounded-xl font-sans font-semibold text-sm text-white bg-gradient-to-r from-red-600 to-pink-600 hover:shadow-lg hover:scale-105 transition-all duration-200 transform"
aria-label="Aceitar"
>
Aceitar
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
const Playground = lazy(() => import("./pages/Playground/Playground"));
const About = lazy(() => import("./pages/About/About"));
@@ -20,6 +114,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 +160,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 (
<>
<ScrollToTop />
@@ -79,6 +180,7 @@ function AppRoutes() {
<Route path="/laboratorio-python" element={<LabPython />} />
<Route path="/sobre" element={<About />} />
<Route path="/faq" element={<Faq />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
<Route path="/atividades" element={<Atividades />} />
<Route path="/educadores" element={<Educadores />} />
<Route path="/iniciativas" element={<Iniciativas />} />
@@ -107,11 +209,26 @@ function AppRoutes() {
}
export default function App() {
const [analyticsReady, setAnalyticsReady] = useState(false);
useEffect(() => {
const hasConsent = ConsentManager.hasConsent();
initializeAnalytics({
providerType: analyticsConfig.providerType,
measurementId: analyticsConfig.measurementId,
hasConsent,
debugMode: analyticsConfig.debugMode,
});
setAnalyticsReady(true);
}, []);
return (
<Router>
<Suspense fallback={<LoadingFallback />}>
<AppRoutes />
<AppRoutes analyticsReady={analyticsReady} />
</Suspense>
<CookieBanner />
</Router>
);
}

View File

@@ -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;

View File

@@ -0,0 +1 @@
/* CookieBanner styles are now handled by Tailwind CSS in the JSX component */

View File

@@ -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 (
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 md:p-6" role="dialog" aria-label="Consentimento de cookies">
<div className="mx-auto max-w-4xl">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-gray-200">
<div className="h-1 bg-gradient-to-r from-red-600 to-pink-600"></div>
<div className="p-6 md:p-8">
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start">
<div className="flex-1 min-w-0">
<h2 className="font-title font-bold text-xl md:text-2xl text-gray-900 mb-2">
Sua privacidade é importante
</h2>
<p className="font-sans text-sm md:text-base text-gray-700 leading-relaxed">
Utilizamos dados sobre como você usa a plataforma para melhorar a experiência educacional
e entender quais recursos são mais úteis.{' '}
<a
href="#/privacy-policy"
className="text-red-600 hover:text-red-700 font-semibold underline transition-colors"
>
Saiba mais
</a>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full md:w-auto flex-shrink-0">
<button
onClick={handleReject}
className="px-6 py-2.5 rounded-xl font-sans font-semibold text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 transition-all duration-200 border border-gray-300"
aria-label="Rejeitar"
>
Rejeitar
</button>
<button
onClick={handleAccept}
className="px-6 py-2.5 rounded-xl font-sans font-semibold text-sm text-white bg-gradient-to-r from-red-600 to-pink-600 hover:shadow-lg hover:scale-105 transition-all duration-200 transform"
aria-label="Aceitar"
>
Aceitar
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -146,7 +146,7 @@ function GameBaseContent({
currentPhase={currentPhase}
gameConfig={gameConfig}
onChangePhase={(fase) => {
setCurrentPhase(fase);
setCurrentPhase(fase, "manual_selector");
setModalFasesAberto(false);
}}
onResetProgress={handleResetProgresso}

View File

@@ -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) {

View File

@@ -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("");

View File

@@ -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();

View File

@@ -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) {

View File

@@ -177,6 +177,19 @@ export default function Faq() {
</div>
))}
</dl>
<div className="mt-12 pt-8 border-t border-gray-300">
<p className="text-gray-600 text-sm">
Dúvidas sobre privacidade e como coletamos dados?
{" "}
<a
href="#/privacy-policy"
className="text-blue-600 hover:text-blue-700 font-semibold"
>
Leia nossa Política de Privacidade
</a>
</p>
</div>
</div>
</div>
</section>

View File

@@ -57,6 +57,14 @@ const Footer = () => {
FAQ
</Link>
</li>
<li>
<Link
to="/privacy-policy"
className="text-gray-600 hover:text-blue-600 dark:hover:text-brand-500 transition-colors"
>
Privacidade
</Link>
</li>
</ul>
</div>

View File

@@ -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 */}
<Navbar />
<section className="mt-32 lg:mt-[195px] bg-gray-100 px-10 md:px-0">
{/* Container principal */}
<div className="mx-auto max-w-7xl py-24 sm:py-32 lg:py-40">
<div className="mx-auto max-w-4xl">
<h2 className="font-bold text-gray-900 tracking-tight text-4xl sm:text-5xl">
Política de Privacidade
</h2>
<p className="mt-6 text-gray-700 text-base leading-relaxed">
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.
</p>
<dl className="mt-10 space-y-4 divide-y divide-gray-300">
{privacyData.map((item, index) => (
<div
key={item.id}
className={index === 0 ? "pt-3 pb-6" : "pt-4 pb-6"}
>
<input
type="checkbox"
id={`privacy-toggle-${item.id}`}
className="peer hidden"
/>
<label
htmlFor={`privacy-toggle-${item.id}`}
className="flex w-full items-start justify-between text-left cursor-pointer"
>
<span className="font-semibold text-gray-900 tracking-tight text-base">
{item.question}
</span>
<span className="ml-6 flex h-7 items-center text-blue-600">
<svg
className="size-6 peer-checked:hidden"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
width="16"
height="16"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v12m6-6H6"
/>
</svg>
<svg
className="hidden size-6 peer-checked:block"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
width="16"
height="16"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 12H6"
/>
</svg>
</span>
</label>
<dd className="pr-12 max-h-0 overflow-hidden peer-checked:max-h-[1000px] peer-checked:pt-6 transition-all duration-300">
<p className="font-normal text-gray-700 text-base">
{item.answer}
</p>
{item.list && (
<ul className="list-disc list-inside space-y-1 text-gray-700 text-base mt-2">
{item.list.map((listItem, idx) => (
<li key={idx}>{listItem}</li>
))}
</ul>
)}
</dd>
</div>
))}
</dl>
<div className="mt-12 pt-8 border-t border-gray-300">
<p className="text-gray-600 text-sm">
<strong>Última atualização:</strong> {new Date().toLocaleDateString('pt-BR')}
</p>
<p className="text-gray-600 text-sm mt-4">
Se você tiver dúvidas sobre nossa Política de Privacidade, entre em contato conosco.
</p>
</div>
</div>
</div>
</section>
{/* Footer */}
<Footer />
</>
);
}

View File

@@ -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;
}

View File

@@ -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 = [];
}
}

View File

@@ -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();

View File

@@ -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');
});
});
});

View File

@@ -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,
};

View File

@@ -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',
});
}

View File

@@ -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';

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}

View File

@@ -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,
};
}

View File

@@ -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]);
}

View File

@@ -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,
};
}

View File

@@ -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]);
}

View File

@@ -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);
},
};

View File

@@ -0,0 +1,2 @@
export { ConsentManager } from './ConsentManager';
export { useConsent } from './useConsent';

View File

@@ -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,
};
}

File diff suppressed because one or more lines are too long