Merge pull request 'feature/ga4' (#2) from feature/ga4 into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -144,4 +144,10 @@ deploy_homolog.sh
|
||||
/SDD
|
||||
app/.vscode/settings.json
|
||||
|
||||
app/specs
|
||||
app/specs
|
||||
# Environment variables - never commit secrets
|
||||
.env
|
||||
.env.*.local
|
||||
app/.env.local
|
||||
app/.env.production
|
||||
app/.env.offline
|
||||
|
||||
45
README.md
45
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
|
||||
|
||||
23
app/.dockerignore
Normal file
23
app/.dockerignore
Normal file
@@ -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
|
||||
45
app/.env.example
Normal file
45
app/.env.example
Normal file
@@ -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
|
||||
#
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,26 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Decoda</title>
|
||||
<script>
|
||||
(async function checkForUpdates() {
|
||||
try {
|
||||
const response = await fetch('/version.json?t=' + Date.now());
|
||||
const newVersion = await response.json();
|
||||
const lastVersion = sessionStorage.getItem('app_version');
|
||||
|
||||
if (lastVersion && lastVersion !== newVersion.commit) {
|
||||
console.log('🔄 Novo deploy detectado. Recarregando...');
|
||||
sessionStorage.setItem('app_version', newVersion.commit);
|
||||
window.location.reload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem('app_version', newVersion.commit);
|
||||
} catch (e) {
|
||||
console.warn('Falha ao verificar atualizações:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
124
app/src/App.jsx
124
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 (
|
||||
<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 +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 (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
@@ -79,6 +182,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 +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 (
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppRoutes />
|
||||
<AppRoutes analyticsReady={analyticsReady} />
|
||||
</Suspense>
|
||||
<CookieBanner />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
app/src/components/CookieBanner/CookieBanner.css
Normal file
1
app/src/components/CookieBanner/CookieBanner.css
Normal file
@@ -0,0 +1 @@
|
||||
/* CookieBanner styles are now handled by Tailwind CSS in the JSX component */
|
||||
90
app/src/components/CookieBanner/CookieBanner.jsx
Normal file
90
app/src/components/CookieBanner/CookieBanner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function GameBaseContent({
|
||||
currentPhase={currentPhase}
|
||||
gameConfig={gameConfig}
|
||||
onChangePhase={(fase) => {
|
||||
setCurrentPhase(fase);
|
||||
setCurrentPhase(fase, "manual_selector");
|
||||
setModalFasesAberto(false);
|
||||
}}
|
||||
onResetProgress={handleResetProgresso}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
35
app/src/contexts/GameStateContext.test.debug.js
Normal file
35
app/src/contexts/GameStateContext.test.debug.js
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
190
app/src/pages/PrivacyPolicy/PrivacyPolicy.jsx
Normal file
190
app/src/pages/PrivacyPolicy/PrivacyPolicy.jsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
app/src/services/analytics/AnalyticsManager.js
Normal file
96
app/src/services/analytics/AnalyticsManager.js
Normal 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;
|
||||
}
|
||||
62
app/src/services/analytics/EventBatcher.js
Normal file
62
app/src/services/analytics/EventBatcher.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
30
app/src/services/analytics/NetworkDetector.js
Normal file
30
app/src/services/analytics/NetworkDetector.js
Normal 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();
|
||||
98
app/src/services/analytics/analytics.test.js
Normal file
98
app/src/services/analytics/analytics.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
app/src/services/analytics/config.js
Normal file
12
app/src/services/analytics/config.js
Normal 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,
|
||||
};
|
||||
53
app/src/services/analytics/googleConsentMode.js
Normal file
53
app/src/services/analytics/googleConsentMode.js
Normal 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',
|
||||
});
|
||||
}
|
||||
8
app/src/services/analytics/index.js
Normal file
8
app/src/services/analytics/index.js
Normal 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';
|
||||
29
app/src/services/analytics/providers/BaseProvider.js
Normal file
29
app/src/services/analytics/providers/BaseProvider.js
Normal 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
|
||||
}
|
||||
}
|
||||
169
app/src/services/analytics/providers/GA4Provider.js
Normal file
169
app/src/services/analytics/providers/GA4Provider.js
Normal 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();
|
||||
}
|
||||
}
|
||||
23
app/src/services/analytics/providers/NoopProvider.js
Normal file
23
app/src/services/analytics/providers/NoopProvider.js
Normal 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
|
||||
}
|
||||
}
|
||||
74
app/src/services/analytics/useActivityTracking.js
Normal file
74
app/src/services/analytics/useActivityTracking.js
Normal 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,
|
||||
};
|
||||
}
|
||||
126
app/src/services/analytics/useGameActivityTracking.js
Normal file
126
app/src/services/analytics/useGameActivityTracking.js
Normal 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]);
|
||||
}
|
||||
68
app/src/services/analytics/useLetramentoTracking.js
Normal file
68
app/src/services/analytics/useLetramentoTracking.js
Normal 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,
|
||||
};
|
||||
}
|
||||
17
app/src/services/analytics/usePageTracking.js
Normal file
17
app/src/services/analytics/usePageTracking.js
Normal 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]);
|
||||
}
|
||||
38
app/src/services/consent/ConsentManager.js
Normal file
38
app/src/services/consent/ConsentManager.js
Normal 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);
|
||||
},
|
||||
};
|
||||
2
app/src/services/consent/index.js
Normal file
2
app/src/services/consent/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ConsentManager } from './ConsentManager';
|
||||
export { useConsent } from './useConsent';
|
||||
34
app/src/services/consent/useConsent.js
Normal file
34
app/src/services/consent/useConsent.js
Normal 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
@@ -5,6 +5,9 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
GIT_COMMIT_HASH: ${GIT_COMMIT_HASH:-unknown}
|
||||
VITE_ANALYTICS_PROVIDER: ${VITE_ANALYTICS_PROVIDER:-ga4}
|
||||
VITE_GA4_ID: ${VITE_GA4_ID}
|
||||
VITE_GA4_DEBUG: ${VITE_GA4_DEBUG:-false}
|
||||
restart: always
|
||||
|
||||
docs:
|
||||
|
||||
435
docs/docs/plataforma/arquitetura/analytics.md
Normal file
435
docs/docs/plataforma/arquitetura/analytics.md
Normal file
@@ -0,0 +1,435 @@
|
||||
---
|
||||
sidebar_position: 8
|
||||
title: Analytics - GA4
|
||||
---
|
||||
|
||||
# Analytics e Rastreamento - Google Analytics 4
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O Decoda implementa um sistema de analytics que permite acompanhar o uso da plataforma e entender como os estudantes interagem com as atividades educacionais. O sistema utiliza **Google Analytics 4 (GA4)** como provedor padrão, mas será migrado para solução open source e auto hospedada no futuro.
|
||||
|
||||
### Objetivos
|
||||
|
||||
- 📊 **Acompanhamento de uso** — entender quais atividades são mais usadas
|
||||
- 📈 **Análise de performance** — identificar onde os estudantes têm dificuldade
|
||||
- 📚 **Melhoria educacional** — usar dados para aprimorar o conteúdo
|
||||
- 🔒 **Privacidade** — respeitar escolha do usuário com consentimento prévio
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura do Sistema
|
||||
|
||||
O sistema de analytics é modular e extensível:
|
||||
|
||||
```text
|
||||
src/services/
|
||||
├── analytics/
|
||||
│ ├── AnalyticsManager.js # Orquestrador central
|
||||
│ ├── config.js # Configuração global
|
||||
│ ├── NetworkDetector.js # Detecta conectividade
|
||||
│ ├── googleConsentMode.js # Conformidade GDPR/LGPD
|
||||
│ ├── usePageTracking.js # Hook: rastrear rotas
|
||||
│ ├── useActivityTracking.js # Hook: atividades com fases
|
||||
│ ├── useLetramentoTracking.js # Hook: atividades simples
|
||||
│ ├── providers/
|
||||
│ │ ├── BaseProvider.js # Interface base
|
||||
│ │ ├── GA4Provider.js # Implementação GA4
|
||||
│ │ └── NoopProvider.js # Dummy para offline
|
||||
│ └── index.js # Exports
|
||||
└── consent/
|
||||
├── ConsentManager.js # Gerencia consentimento
|
||||
├── useConsent.js # Hook de acesso
|
||||
└── index.js
|
||||
```
|
||||
|
||||
### Componentes Principais
|
||||
|
||||
#### **AnalyticsManager**
|
||||
|
||||
Orquestrador central que:
|
||||
- Instancia o provedor apropriado (GA4, Noop, etc)
|
||||
- Verifica consentimento antes de rastrear
|
||||
- Verifica conectividade (offline-aware)
|
||||
- Fornece interface unificada para tracking
|
||||
|
||||
#### **Provedores (Providers)**
|
||||
|
||||
Interface plugável para diferentes serviços:
|
||||
|
||||
- **GA4Provider** — Envia eventos para Google Analytics 4
|
||||
- **NoopProvider** — Não faz nada (usado em desenvolvimento)
|
||||
|
||||
#### **ConsentManager**
|
||||
|
||||
Gerencia o consentimento do usuário:
|
||||
- Armazena escolha em localStorage
|
||||
- Inicializa Google Consent Mode
|
||||
- Dispara eventos quando consentimento muda
|
||||
|
||||
---
|
||||
|
||||
## Configuração
|
||||
|
||||
### Variáveis de Ambiente
|
||||
|
||||
Configure o analytics através de variáveis de ambiente no build Docker:
|
||||
|
||||
**Produção (com GA4):**
|
||||
|
||||
```bash
|
||||
VITE_ANALYTICS_PROVIDER=ga4
|
||||
VITE_GA4_ID=G-57HGKF773M
|
||||
VITE_GA4_DEBUG=false
|
||||
```
|
||||
|
||||
**Desenvolvimento (sem rastreamento):**
|
||||
|
||||
```bash
|
||||
VITE_ANALYTICS_PROVIDER=noop
|
||||
VITE_GA4_ID=
|
||||
```
|
||||
|
||||
### Arquivo `.env.example`
|
||||
|
||||
```bash
|
||||
# Analytics Provider: 'ga4' ou 'noop'
|
||||
VITE_ANALYTICS_PROVIDER=noop
|
||||
|
||||
# Google Analytics 4 ID (ID de medição do GA4)
|
||||
# Obtenha em: https://analytics.google.com → Data Streams → Web
|
||||
VITE_GA4_ID=
|
||||
|
||||
# Debug mode para GA4 (mostra eventos no console)
|
||||
VITE_GA4_DEBUG=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Docker com Analytics
|
||||
|
||||
### Build Básico (Desenvolvimento)
|
||||
|
||||
```bash
|
||||
docker compose build app
|
||||
docker compose up app
|
||||
```
|
||||
|
||||
### Build com GA4 (Produção)
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg VITE_ANALYTICS_PROVIDER=ga4 \
|
||||
--build-arg VITE_GA4_ID=G-SEU_ID_AQUI \
|
||||
--build-arg GIT_COMMIT_HASH=$(git rev-parse --short HEAD) app
|
||||
```
|
||||
|
||||
### 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-57HGKF773M
|
||||
VITE_GA4_DEBUG=false
|
||||
```
|
||||
|
||||
Então execute:
|
||||
|
||||
```bash
|
||||
docker compose build app
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Usando docker build diretamente
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg GIT_COMMIT_HASH=$(git rev-parse --short HEAD) \
|
||||
--build-arg VITE_ANALYTICS_PROVIDER=ga4 \
|
||||
--build-arg VITE_GA4_ID=G-SEU_ID_AQUI \
|
||||
-t decoda:latest \
|
||||
./app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de Consentimento
|
||||
|
||||
O sistema implementa um fluxo LGPD/GDPR-compliant:
|
||||
|
||||
```
|
||||
Primeiro acesso
|
||||
↓
|
||||
[CookieBanner aparece]
|
||||
↓
|
||||
Usuário escolhe
|
||||
├─→ "Aceitar"
|
||||
│ ├─ Armazena consentimento em localStorage
|
||||
│ ├─ Carrega GA4 script
|
||||
│ ├─ Inicializa Google Consent Mode
|
||||
│ └─ Começa a rastrear
|
||||
│
|
||||
└─→ "Rejeitar"
|
||||
├─ Armazena consentimento=false em localStorage
|
||||
├─ Carrega NoopProvider
|
||||
└─ Não rastreia nada
|
||||
```
|
||||
|
||||
### Armazenamento
|
||||
|
||||
- Chave: `decoda_consent`
|
||||
- Valor: `true` (aceito) ou `false` (rejeitado)
|
||||
- Local: localStorage (persiste entre sessões)
|
||||
|
||||
---
|
||||
|
||||
## Rastreamento de Eventos
|
||||
|
||||
### Page Views (Automático)
|
||||
|
||||
Toda mudança de rota dispara automaticamente:
|
||||
|
||||
```javascript
|
||||
page_view {
|
||||
page_path: '/atividades/puzzle',
|
||||
page_title: 'Puzzle Game',
|
||||
language: 'pt-BR'
|
||||
}
|
||||
```
|
||||
|
||||
**Integração:** Hook `usePageTracking()` no `App.jsx`
|
||||
|
||||
### Atividades de Programação (Com Fases)
|
||||
|
||||
Para atividades com múltiplas fases como Puzzle, Aspirador, etc:
|
||||
|
||||
```javascript
|
||||
import { useActivityTracking } from '@/services/analytics';
|
||||
|
||||
export default function PuzzleGame() {
|
||||
const { trackPhaseCompletion } = useActivityTracking(gameConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (phaseCompleted) {
|
||||
trackPhaseCompletion(phaseId, phaseName, success);
|
||||
}
|
||||
}, [phaseCompleted]);
|
||||
|
||||
return <GameBase gameFactory={createGame} gameConfig={gameConfig} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Eventos gerados:**
|
||||
- `fase_completada` — quando fase é resolvida
|
||||
- `fase_falhou` — quando usuário falha
|
||||
- `atividade_abandonada` — quando sai sem terminar
|
||||
|
||||
**Dados rastreados:**
|
||||
- `atividade_id`, `atividade_nome`
|
||||
- `fase_numero`, `fase_nome`
|
||||
- `tempo_sessao_segundos`
|
||||
- `categoria` (ex: Variáveis, Sequências)
|
||||
|
||||
### Atividades de Letramento (Simples)
|
||||
|
||||
Para atividades sem fases como Mouse, Teclado:
|
||||
|
||||
```javascript
|
||||
import { useLetramentoTracking } from '@/services/analytics';
|
||||
|
||||
export function MouseBasico() {
|
||||
const { trackCompletion } = useLetramentoTracking('mouse-basico', 'Mouse');
|
||||
|
||||
return (
|
||||
<button onClick={() => trackCompletion(true)}>
|
||||
Concluir
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Eventos gerados:**
|
||||
- `letramento_atividade_completada` — sucesso
|
||||
- `letramento_atividade_falhou` — falha
|
||||
|
||||
### Rastreamento Customizado
|
||||
|
||||
```javascript
|
||||
import { getAnalytics } from '@/services/analytics';
|
||||
|
||||
const analytics = getAnalytics();
|
||||
analytics.trackEvent('hint_used', {
|
||||
activity: 'puzzle-fase-1',
|
||||
hint_type: 'algorithm',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamento Offline
|
||||
|
||||
O Decoda é projetado para funcionar sem internet:
|
||||
|
||||
- **Quando offline**: Eventos são ignorados silenciosamente
|
||||
- **Sem armazenamento em fila**: Não há fila local de eventos
|
||||
- **Quando reconecta**: Apenas eventos após reconexão são rastreados
|
||||
- **Sem erros**: Falhas de analytics não afetam a app
|
||||
|
||||
---
|
||||
|
||||
## Relatórios no GA4
|
||||
|
||||
### Acessar a Propriedade
|
||||
|
||||
1. Abra [https://analytics.google.com](https://analytics.google.com)
|
||||
2. Selecione propriedade "Decoda"
|
||||
3. Navegue para relatórios
|
||||
|
||||
### Relatórios Úteis
|
||||
|
||||
**Dashboard em Tempo Real:**
|
||||
- `Relatórios → Em tempo real`
|
||||
- Vê eventos acontecendo agora
|
||||
- Útil para testar implementação
|
||||
|
||||
**Atividades mais usadas:**
|
||||
- `Relatórios → Envolvimento → Eventos`
|
||||
- Filtrar por `page_path` contendo `/atividades/`
|
||||
- Agregar por `page_title`
|
||||
|
||||
**Taxa de conclusão por fase:**
|
||||
- `Relatórios → Envolvimento → Eventos`
|
||||
- Procurar por `fase_completada`
|
||||
- Agrupar por `fase_numero`
|
||||
|
||||
**Tempo gasto por atividade:**
|
||||
- `Relatórios → Envolvimento → Páginas e telas`
|
||||
- Ver `tempo_médio_na_página`
|
||||
|
||||
---
|
||||
|
||||
## Offline-First Build
|
||||
|
||||
Para criar uma build sem analytics (offline):
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg VITE_ANALYTICS_PROVIDER=noop app
|
||||
```
|
||||
|
||||
Ou via arquivo `.env`:
|
||||
|
||||
```bash
|
||||
VITE_ANALYTICS_PROVIDER=noop
|
||||
```
|
||||
|
||||
Depois:
|
||||
|
||||
```bash
|
||||
pnpm run build:offline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Política de Privacidade
|
||||
|
||||
A página `/privacy-policy` está integrada na app e explica:
|
||||
|
||||
- ✅ O que é rastreado (page views, eventos de atividades)
|
||||
- ✅ Quem coleta (Google Analytics)
|
||||
- ✅ Como optar por não ser rastreado (cookies)
|
||||
- ✅ Conformidade LGPD e GDPR
|
||||
|
||||
---
|
||||
|
||||
## Google Consent Mode
|
||||
|
||||
O sistema implementa [Google Consent Mode](https://support.google.com/analytics/answer/9976101) para conformidade regulatória:
|
||||
|
||||
- **analytics_storage** — controlado por consentimento do usuário
|
||||
- **ad_storage** — desabilitado (Decoda não exibe anúncios)
|
||||
- **ad_personalization** — desabilitado
|
||||
|
||||
Garante que GA4 respeita a escolha GDPR/LGPD do usuário.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### GA4 não está rastreando
|
||||
|
||||
**Checklist:**
|
||||
|
||||
1. ✅ Variável `VITE_ANALYTICS_PROVIDER=ga4`?
|
||||
2. ✅ Variável `VITE_GA4_ID` configurada corretamente?
|
||||
3. ✅ Usuário aceitou cookies?
|
||||
4. ✅ Verificou em GA4 Real-time?
|
||||
|
||||
**Debug:**
|
||||
|
||||
```bash
|
||||
# Ativar debug mode
|
||||
VITE_GA4_DEBUG=true docker compose build app
|
||||
```
|
||||
|
||||
Procure por logs no console do browser:
|
||||
|
||||
```
|
||||
GA4: Tracking event 'page_view'
|
||||
GA4: Event sent successfully
|
||||
```
|
||||
|
||||
### Eventos não aparecem em GA4
|
||||
|
||||
- GA4 leva até 24h para processar eventos
|
||||
- Use `Relatórios → Em tempo real` para verificação imediata
|
||||
- Verifique se consentimento foi aceito (`localStorage.getItem('decoda_consent')`)
|
||||
|
||||
### Banner de cookies não aparece
|
||||
|
||||
```javascript
|
||||
// No console do browser:
|
||||
localStorage.removeItem('decoda_consent');
|
||||
location.reload();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extensibilidade
|
||||
|
||||
Para adicionar um novo provedor (ex: Umami, Plausible):
|
||||
|
||||
1. **Crie arquivo:** `src/services/analytics/providers/UmamiProvider.js`
|
||||
|
||||
```javascript
|
||||
import { BaseProvider } from './BaseProvider';
|
||||
|
||||
export class UmamiProvider extends BaseProvider {
|
||||
async initialize() {
|
||||
// Carregar script Umami
|
||||
}
|
||||
|
||||
trackPageView(data) {
|
||||
// Implementar rastreamento
|
||||
}
|
||||
|
||||
trackEvent(eventName, eventData) {
|
||||
// Implementar rastreamento
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Registre em:** `src/services/analytics/config.js`
|
||||
|
||||
3. **Atualize variáveis:** `.env.example` e `Dockerfile`
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- [Google Analytics 4 Docs](https://developers.google.com/analytics/devguides/collection/ga4)
|
||||
- [Google Consent Mode](https://support.google.com/analytics/answer/9976101)
|
||||
- [LGPD Lei 13.709](http://www.planalto.gov.br/ccivil_03/_ato2015-2018/2015/lei/l13105.htm)
|
||||
- [GDPR Official](https://gdpr-info.eu/)
|
||||
|
||||
Veja também: `app/src/services/analytics/ACTIVITY_TRACKING.md` para documentação detalhada de integração em atividades.
|
||||
@@ -30,4 +30,13 @@ A aplicação se organiza em quatro camadas práticas:
|
||||
- Atividades de programação em `app/src/atividades/programacao`.
|
||||
- Atividades de letramento em `app/src/atividades/letramento`.
|
||||
- Componentes compartilhados em `app/src/components`.
|
||||
- Estado compartilhado em `app/src/contexts`.
|
||||
- Estado compartilhado em `app/src/contexts`.
|
||||
|
||||
## Documentação disponível nesta seção
|
||||
|
||||
- **[Analytics - GA4](analytics.md)** — Sistema de rastreamento de uso com Google Analytics 4
|
||||
- **[Versionamento e Atualizações](versionamento-atualizacoes.md)** — Detecção automática de deploys e recarregamento
|
||||
- **[Camadas do Sistema](camadas-do-sistema.md)** — Organização técnica das camadas
|
||||
- **[Padrões e Conventions](patterns.md)** — Padrões de código e design
|
||||
- **[Otimização de Bundle](otimizacao-bundle.md)** — Estratégias de compressão e carregamento
|
||||
- Outras documentações técnicas da plataforma
|
||||
329
docs/docs/plataforma/arquitetura/versionamento-atualizacoes.md
Normal file
329
docs/docs/plataforma/arquitetura/versionamento-atualizacoes.md
Normal file
@@ -0,0 +1,329 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
title: Versionamento e Detecção de Atualizações
|
||||
---
|
||||
|
||||
# Versionamento e Detecção Automática de Atualizações
|
||||
|
||||
## Visão Geral
|
||||
|
||||
A plataforma Decoda implementa um sistema automático de detecção de atualizações que recarrega a aplicação quando um novo deploy é detectado. Isso garante que os usuários sempre utilizem a versão mais recente sem necessidade de ação manual.
|
||||
|
||||
---
|
||||
|
||||
## Como Funciona
|
||||
|
||||
### Arquivo `version.json`
|
||||
|
||||
Durante o build Docker, o Dockerfile gera um arquivo `version.json` contendo metadados de versão:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.1.3",
|
||||
"commit": "abc1234def5678",
|
||||
"buildDate": "2026-06-05T10:30:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
Este arquivo é:
|
||||
- ✅ Gerado a cada build
|
||||
- ✅ Servido pelo Nginx junto com os arquivos estáticos
|
||||
- ✅ Sempre atualizado (sem cache)
|
||||
- ✅ Acessível em `/version.json`
|
||||
|
||||
**Geração no Dockerfile:**
|
||||
|
||||
```dockerfile
|
||||
RUN echo "{\"version\": \"$APP_VERSION\", \"commit\": \"$GIT_COMMIT_HASH\", \"buildDate\": \"$(date)\"}" > public/version.json
|
||||
```
|
||||
|
||||
### Script de Detecção em `index.html`
|
||||
|
||||
No `<head>` da página, um script verifica atualizações:
|
||||
|
||||
```javascript
|
||||
(async function checkForUpdates() {
|
||||
try {
|
||||
// Busca a versão atual do servidor
|
||||
const response = await fetch('/version.json?t=' + Date.now());
|
||||
const newVersion = await response.json();
|
||||
const lastVersion = sessionStorage.getItem('app_version');
|
||||
|
||||
// Se houver versão anterior e for diferente da atual
|
||||
if (lastVersion && lastVersion !== newVersion.commit) {
|
||||
console.log('🔄 Novo deploy detectado. Recarregando...');
|
||||
sessionStorage.setItem('app_version', newVersion.commit);
|
||||
window.location.reload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Armazena versão atual para próxima verificação
|
||||
sessionStorage.setItem('app_version', newVersion.commit);
|
||||
} catch (e) {
|
||||
console.warn('Falha ao verificar atualizações:', e);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
### Fluxo de Detecção
|
||||
|
||||
```
|
||||
1. Usuário acessa a página (GET /)
|
||||
↓
|
||||
2. Script em index.html executa
|
||||
├─ Faz fetch de /version.json (sem cache: ?t=Date.now())
|
||||
├─ Compara commit com sessionStorage
|
||||
│
|
||||
├─ Se não há versão anterior:
|
||||
│ └─ Armazena e continua
|
||||
│
|
||||
└─ Se versão mudou:
|
||||
├─ Armazena nova versão
|
||||
├─ Log: "🔄 Novo deploy detectado"
|
||||
└─ window.location.reload(true) → força reload do cache
|
||||
```
|
||||
|
||||
### Parâmetro `?t=Date.now()`
|
||||
|
||||
O parâmetro `?t=` garante que o navegador **não use o cache** de `/version.json`:
|
||||
|
||||
- **Sem o parâmetro:** Browser pode servir versão antiga do cache
|
||||
- **Com o parâmetro:** Browser sempre faz nova requisição
|
||||
- **Resultado:** Atualizações são detectadas na primeira página acessada
|
||||
|
||||
### `window.location.reload(true)`
|
||||
|
||||
O parâmetro `true` força reload ignorando cache:
|
||||
|
||||
```javascript
|
||||
window.location.reload(true); // ✅ Ignora cache, carrega do servidor
|
||||
window.location.reload(); // ❌ Pode usar cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Armazenamento em sessionStorage
|
||||
|
||||
O commit hash é armazenado em `sessionStorage` (não `localStorage`):
|
||||
|
||||
- **sessionStorage** — Limpo ao fechar a aba/navegador
|
||||
- **localStorage** — Persiste entre sessões
|
||||
|
||||
Usar `sessionStorage` garante que:
|
||||
- Verificação acontece em cada nova aba/janela
|
||||
- Histórico de versões não acumula
|
||||
- Usuário tem sempre a última versão do servidor
|
||||
|
||||
---
|
||||
|
||||
## Metadados Disponíveis
|
||||
|
||||
O arquivo `/version.json` contém:
|
||||
|
||||
| Campo | Exemplo | Descrição |
|
||||
|---|---|---|
|
||||
| `version` | `1.1.3` | Versão semântica da app |
|
||||
| `commit` | `abc1234def5678` | Hash curto do commit Git |
|
||||
| `buildDate` | `2026-06-05T10:30:45Z` | Data/hora do build |
|
||||
|
||||
Exemplo de acesso programático:
|
||||
|
||||
```javascript
|
||||
// Obter informações de versão no console
|
||||
fetch('/version.json')
|
||||
.then(r => r.json())
|
||||
.then(v => console.log(v));
|
||||
|
||||
// Output:
|
||||
// { version: "1.1.3", commit: "abc1234", buildDate: "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variáveis de Build
|
||||
|
||||
O Dockerfile recebe argumentos para gerar a versão:
|
||||
|
||||
```dockerfile
|
||||
ARG GIT_COMMIT_HASH=unknown
|
||||
ARG APP_VERSION=1.1.3
|
||||
|
||||
RUN echo "{\"version\": \"$APP_VERSION\", \"commit\": \"$GIT_COMMIT_HASH\", ...}" > public/version.json
|
||||
```
|
||||
|
||||
### Passar argumentos
|
||||
|
||||
**Via Docker Compose:**
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg GIT_COMMIT_HASH=$(git rev-parse --short HEAD) app
|
||||
```
|
||||
|
||||
**Via arquivo `.env`:**
|
||||
|
||||
```bash
|
||||
GIT_COMMIT_HASH=abc1234
|
||||
```
|
||||
|
||||
**Via docker build direto:**
|
||||
|
||||
```bash
|
||||
docker build --build-arg GIT_COMMIT_HASH=abc1234 -t decoda:latest ./app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Casos de Uso
|
||||
|
||||
### Usuário deixa a página aberta
|
||||
|
||||
```
|
||||
14:00 — Usuário acessa /atividades
|
||||
Script detecta versão "abc1234"
|
||||
|
||||
14:15 — Deploy realizado com commit "def5678"
|
||||
|
||||
14:20 — Usuário navega para /atividades/puzzle
|
||||
Script detecta versão mudou
|
||||
→ Recarrega página com novo código
|
||||
```
|
||||
|
||||
### Novo deploy, usuário abre nova aba
|
||||
|
||||
```
|
||||
14:00 — Deploy realizado com versão "def5678"
|
||||
|
||||
14:05 — Usuário abre nova aba (GET /)
|
||||
Script detecta versão "def5678"
|
||||
→ Versão nova é carregada na aba
|
||||
```
|
||||
|
||||
### Offline, usuário reconecta
|
||||
|
||||
```
|
||||
14:00 — Usuário offline, página em cache
|
||||
Script falha ao fetch /version.json
|
||||
→ Log: "Falha ao verificar atualizações"
|
||||
→ Usuário continua com versão anterior
|
||||
|
||||
14:15 — Usuário reconecta e navega
|
||||
|
||||
14:20 — Script detecta nova versão
|
||||
→ Recarrega com versão atualizada
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tratamento de Erros
|
||||
|
||||
O script trata erros graciosamente:
|
||||
|
||||
```javascript
|
||||
catch (e) {
|
||||
console.warn('Falha ao verificar atualizações:', e);
|
||||
}
|
||||
```
|
||||
|
||||
**Cenários:**
|
||||
|
||||
- ✅ **Offline** — Erro de network, silencioso, continua rodando
|
||||
- ✅ **JSON inválido** — Parse error, log, continua rodando
|
||||
- ✅ **Timeout** — Requisição fica pendurada, timeout natural
|
||||
- ✅ **Nginx indisponível** — Erro 500/502, log, continua rodando
|
||||
|
||||
**Resultado:** Sempre funciona, nunca quebra a página
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Ver versão atual no console
|
||||
|
||||
```javascript
|
||||
sessionStorage.getItem('app_version')
|
||||
// Output: "abc1234def5678"
|
||||
```
|
||||
|
||||
### Forçar reload de versão
|
||||
|
||||
```javascript
|
||||
sessionStorage.removeItem('app_version');
|
||||
location.reload();
|
||||
// Próximo acesso detectará e armazenará nova versão
|
||||
```
|
||||
|
||||
### Inspecionar /version.json
|
||||
|
||||
```bash
|
||||
# Terminal
|
||||
curl http://localhost/version.json
|
||||
|
||||
# Browser DevTools
|
||||
fetch('/version.json').then(r => r.json()).then(console.log)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integração com Analytics
|
||||
|
||||
O commit hash também é enviado para Google Analytics:
|
||||
|
||||
```javascript
|
||||
// Em usePageTracking.js
|
||||
analytics.trackPageView({
|
||||
page_path: location.pathname,
|
||||
page_title: document.title,
|
||||
git_commit: window.APP_VERSION?.commit, // Hash do deploy
|
||||
});
|
||||
```
|
||||
|
||||
Permite rastrear qual versão o usuário estava utilizando quando completou uma atividade.
|
||||
|
||||
---
|
||||
|
||||
## Boas Práticas
|
||||
|
||||
1. **Sempre passar `GIT_COMMIT_HASH`** — Facilita debugging
|
||||
```bash
|
||||
docker compose build --build-arg GIT_COMMIT_HASH=$(git rev-parse --short HEAD) app
|
||||
```
|
||||
|
||||
2. **Usar arquivo `.env`** — Mais limpo e repetível
|
||||
```bash
|
||||
GIT_COMMIT_HASH=$(git rev-parse --short HEAD) docker compose up --build
|
||||
```
|
||||
|
||||
3. **Monitorar logs** — Verifique `console.log('🔄 Novo deploy detectado')`
|
||||
```bash
|
||||
# Browser DevTools → Console
|
||||
```
|
||||
|
||||
4. **Testar localmente** — Modifique `version.json` e navegue para testar
|
||||
|
||||
---
|
||||
|
||||
## Limitações e Considerações
|
||||
|
||||
| Aspecto | Descrição |
|
||||
|---|---|
|
||||
| **Timing** | Detecta somente quando usuário acessa página, não em tempo real |
|
||||
| **sessionStorage** | Limpo ao fechar aba, não persiste entre navegadores |
|
||||
| **SPA** | Apenas recarrega ao navegar, não há polling contínuo |
|
||||
| **Sem notificação** | Recarrega silenciosamente, sem avisar usuário |
|
||||
|
||||
### Se precisar de comportamento diferente
|
||||
|
||||
- **Polling contínuo** — Usar `setInterval()` em vez de só no load
|
||||
- **Notificação ao usuário** — Mostrar modal antes de reload
|
||||
- **localStorage** — Persistir versão entre abas/sessões
|
||||
- **Update prompts** — Permitir usuário decidir quando recarregar
|
||||
|
||||
Veja [service-workers.md](sistema-tours.md) para implementações mais avançadas com PWA.
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- [sessionStorage MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)
|
||||
- [HTTP Cache Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
|
||||
- [Service Workers para atualizações](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
|
||||
@@ -58,5 +58,17 @@ gitGraph
|
||||
checkout develop
|
||||
commit id: "ordenação, offline, segurança"
|
||||
checkout main
|
||||
merge develop id: "1.1.0 — Em desenvolvimento" tag: "v1.1.0"
|
||||
merge develop id: "1.1.0 — Release" tag: "v1.1.0"
|
||||
checkout develop
|
||||
commit id: "analytics, detecção de atualizações"
|
||||
checkout main
|
||||
merge develop id: "1.1.3 — Analytics e atualização" tag: "v1.1.3"
|
||||
```
|
||||
|
||||
## Histórico de Versões
|
||||
|
||||
| Versão | Data | Destaque Principal |
|
||||
|---|---|---|
|
||||
| [**v1.1.3**](v1.1.3.md) | 05/06/2026 | Analytics com Google Analytics 4 e detecção de atualizações automáticas |
|
||||
| [**v1.1.0**](v1.1.0.md) | 14/07/2026 | Atividade de Ordenação, suporte offline (PWA) e segurança XSS |
|
||||
| **v1.0.0** | — | Lançamento inicial da plataforma |
|
||||
|
||||
163
docs/docs/releases/v1.1.3.md
Normal file
163
docs/docs/releases/v1.1.3.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: "1.1.3"
|
||||
---
|
||||
|
||||
# 1.1.3
|
||||
|
||||
**Data de lançamento:** 05/06/2026
|
||||
|
||||
---
|
||||
|
||||
## Adicionado
|
||||
|
||||
### Analytics - Google Analytics 4 (GA4)
|
||||
|
||||
Integração completa de analytics para rastreamento de uso e experiência dos estudantes:
|
||||
|
||||
- **Rastreamento de páginas** — acompanha quais seções da plataforma são mais visitadas
|
||||
- **Rastreamento de atividades** — identifica quais atividades educacionais são completadas, falhadas ou abandonadas
|
||||
- **Rastreamento de fases** — permite entender em qual fase os estudantes têm dificuldade
|
||||
- **Offline-aware** — não tenta enviar dados quando o estudante está sem conexão
|
||||
- **Configurável** — pode ser habilitado ou desabilitado via variáveis de ambiente
|
||||
|
||||
#### Como usar
|
||||
|
||||
**Development (sem analytics):**
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg VITE_ANALYTICS_PROVIDER=noop app
|
||||
```
|
||||
|
||||
**Production (com GA4):**
|
||||
|
||||
```bash
|
||||
docker compose build \
|
||||
--build-arg VITE_ANALYTICS_PROVIDER=ga4 \
|
||||
--build-arg VITE_GA4_ID=G-SEU_ID_AQUI \
|
||||
--build-arg GIT_COMMIT_HASH=$(git rev-parse --short HEAD) app
|
||||
```
|
||||
|
||||
Veja a [documentação completa de Analytics](../plataforma/arquitetura/analytics.md) para detalhes de integração e relatórios.
|
||||
|
||||
### Detecção Automática de Atualizações
|
||||
|
||||
Script no `index.html` que detecta novos deploys e recarrega automaticamente:
|
||||
|
||||
```javascript
|
||||
(async function checkForUpdates() {
|
||||
const response = await fetch('/version.json');
|
||||
const newVersion = await response.json();
|
||||
const lastVersion = sessionStorage.getItem('app_version');
|
||||
|
||||
if (lastVersion && lastVersion !== newVersion.commit) {
|
||||
console.log('🔄 Novo deploy detectado. Recarregando...');
|
||||
window.location.reload(true);
|
||||
}
|
||||
|
||||
sessionStorage.setItem('app_version', newVersion.commit);
|
||||
})();
|
||||
```
|
||||
|
||||
**Benefícios:**
|
||||
- Usuários recebem novas versões automaticamente
|
||||
- Sem necessidade de limpar cache ou recarregar manual
|
||||
- Transparente — nenhuma intervenção do usuário necessária
|
||||
- Apenas recarrega se houver mudança de versão
|
||||
|
||||
---
|
||||
|
||||
## Melhorias
|
||||
|
||||
### Build Docker Aprimorado
|
||||
|
||||
O `Dockerfile` agora suporta argumentos de build para configurar analytics e versionamento:
|
||||
|
||||
```dockerfile
|
||||
ARG GIT_COMMIT_HASH=unknown
|
||||
ARG APP_VERSION=1.1.3
|
||||
ARG VITE_ANALYTICS_PROVIDER=ga4
|
||||
ARG VITE_GA4_ID=SEU_ID_AQUI
|
||||
ARG VITE_GA4_DEBUG=false
|
||||
```
|
||||
|
||||
Permite builds reproduzíveis e controlados por ambiente.
|
||||
|
||||
---
|
||||
|
||||
## Segurança
|
||||
|
||||
### Conformidade LGPD/GDPR
|
||||
|
||||
- Banner de consentimento antes de qualquer rastreamento
|
||||
- Implementação de Google Consent Mode
|
||||
- Opção clara de rejeitar analytics
|
||||
- Dados anonimizados (anonymize_ip: true)
|
||||
- Política de Privacidade atualizada em `/privacy-policy`
|
||||
|
||||
---
|
||||
|
||||
## Documentação
|
||||
|
||||
Documentação técnica completa adicionada:
|
||||
|
||||
- **[Analytics - GA4](../plataforma/arquitetura/analytics.md)** — Visão geral, arquitetura, build e relatórios
|
||||
- **[Versionamento e Atualizações](../plataforma/arquitetura/versionamento-atualizacoes.md)** — Detecção automática de deploys
|
||||
|
||||
---
|
||||
|
||||
## Notas de Deploy
|
||||
|
||||
### Primeiro Deploy com Analytics
|
||||
|
||||
Ao fazer deploy em produção com GA4:
|
||||
|
||||
1. Configure as variáveis de ambiente:
|
||||
```bash
|
||||
VITE_ANALYTICS_PROVIDER=ga4
|
||||
VITE_GA4_ID=G-SEU_ID_AQUI
|
||||
```
|
||||
|
||||
2. Obtenha o ID do GA4:
|
||||
- Acesse [https://analytics.google.com](https://analytics.google.com)
|
||||
- Vá para `Administração → Data Streams`
|
||||
- Copie o ID de medição (formato: G-XXXXXXXXXX)
|
||||
|
||||
3. Compile com:
|
||||
```bash
|
||||
docker compose build --build-arg VITE_GA4_ID=G-SEU_ID app
|
||||
```
|
||||
|
||||
4. Teste em produção:
|
||||
- Abra a app no browser
|
||||
- Aceite cookies quando o banner aparecer
|
||||
- Abra DevTools → Network
|
||||
- Procure por requisições para `googletagmanager.com`
|
||||
- Verifique `Relatórios → Em tempo real` no GA4
|
||||
|
||||
### Se optar por não usar Analytics
|
||||
|
||||
Para desabilitar analytics em qualquer ambiente:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg VITE_ANALYTICS_PROVIDER=noop app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changelog Completo
|
||||
|
||||
**Adicionado:**
|
||||
- ✨ Analytics com Google Analytics 4 (mas preparado para uso de outras ferramentas no futuro como Umami)
|
||||
- ✨ Detecção automática de atualizações no `index.html`
|
||||
- ✨ Documentação de Analytics e build Docker
|
||||
- ✨ Hooks de rastreamento: `usePageTracking`, `useActivityTracking`, `useLetramentoTracking`
|
||||
|
||||
**Melhorado:**
|
||||
- 🔧 Build Docker com suporte a argumentos de GA4
|
||||
- 🔧 Versionamento integrado (`version.json`)
|
||||
- 📚 Documentação técnica expandida
|
||||
|
||||
**Segurança:**
|
||||
- 🔒 Implementação de Google Consent Mode
|
||||
- 🔒 Anonimização de IPs em GA4
|
||||
Reference in New Issue
Block a user