add analytics
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
|
||||
|
||||
3
app/.env.example
Normal file
3
app/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_ANALYTICS_PROVIDER=ga4
|
||||
VITE_GA4_ID=
|
||||
VITE_GA4_DEBUG=false
|
||||
@@ -3,9 +3,17 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ARG GIT_COMMIT_HASH=unknown
|
||||
ARG APP_VERSION=1.1.2
|
||||
ARG APP_VERSION=1.1.3
|
||||
ARG VITE_ANALYTICS_PROVIDER=ga4
|
||||
ARG VITE_GA4_ID=G-57HGKF773M
|
||||
ARG VITE_GA4_DEBUG=false
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV VITE_APP_VERSION=$APP_VERSION
|
||||
ENV VITE_GIT_HASH=$GIT_COMMIT_HASH
|
||||
ENV VITE_ANALYTICS_PROVIDER=$VITE_ANALYTICS_PROVIDER
|
||||
ENV VITE_GA4_ID=$VITE_GA4_ID
|
||||
ENV VITE_GA4_DEBUG=$VITE_GA4_DEBUG
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
123
app/src/App.jsx
123
app/src/App.jsx
@@ -4,12 +4,106 @@
|
||||
* @module App
|
||||
*/
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { HashRouter as Router, Routes, Route, useLocation } from "react-router-dom";
|
||||
import "./App.css";
|
||||
import HomePage from "./pages/HomePage/HomePage";
|
||||
import LabPython from "./pages/LabPython/LabPython";
|
||||
import ScrollToTop from "./components/ScrollToTop";
|
||||
import { getAnalytics, usePageTracking } from "./services/analytics";
|
||||
import { initializeAnalytics, analyticsConfig } from "./services/analytics";
|
||||
import { ConsentManager } from "./services/consent";
|
||||
|
||||
// Inline CookieBanner para evitar bloqueio do Brave
|
||||
function CookieBanner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('decoda_consent');
|
||||
if (!stored) {
|
||||
setVisible(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Consent check failed:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
try {
|
||||
localStorage.setItem('decoda_consent', JSON.stringify({
|
||||
version: '1',
|
||||
accepted: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
getAnalytics()._setConsentGrantedInternal(true);
|
||||
setVisible(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to accept consent:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
try {
|
||||
localStorage.setItem('decoda_consent', JSON.stringify({
|
||||
version: '1',
|
||||
accepted: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
getAnalytics()._setConsentGrantedInternal(false);
|
||||
setVisible(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to reject consent:', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 md:p-6" role="dialog" aria-label="Consentimento de cookies">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-gray-200">
|
||||
<div className="h-1 bg-gradient-to-r from-red-600 to-pink-600"></div>
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-title font-bold text-xl md:text-2xl text-gray-900 mb-2">
|
||||
Sua privacidade é importante
|
||||
</h2>
|
||||
<p className="font-sans text-sm md:text-base text-gray-700 leading-relaxed">
|
||||
Utilizamos dados sobre como você usa a plataforma para melhorar a experiência educacional
|
||||
e entender quais recursos são mais úteis.{' '}
|
||||
<a
|
||||
href="#/privacy-policy"
|
||||
className="text-red-600 hover:text-red-700 font-semibold underline transition-colors"
|
||||
>
|
||||
Saiba mais
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full md:w-auto flex-shrink-0">
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className="px-6 py-2.5 rounded-xl font-sans font-semibold text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 transition-all duration-200 border border-gray-300"
|
||||
aria-label="Rejeitar"
|
||||
>
|
||||
Rejeitar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="px-6 py-2.5 rounded-xl font-sans font-semibold text-sm text-white bg-gradient-to-r from-red-600 to-pink-600 hover:shadow-lg hover:scale-105 transition-all duration-200 transform"
|
||||
aria-label="Aceitar"
|
||||
>
|
||||
Aceitar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Playground = lazy(() => import("./pages/Playground/Playground"));
|
||||
const About = lazy(() => import("./pages/About/About"));
|
||||
@@ -20,6 +114,11 @@ const Iniciativas = lazy(() => import("./pages/Iniciativas/Iniciativas"));
|
||||
const IniciativaDetalhe = lazy(() => import("./pages/Iniciativas/IniciativaDetalhe"));
|
||||
const PrimeirosPassos = lazy(() => import("./pages/PrimeirosPassos/PrimeirosPassos"));
|
||||
const CategoriaLetramentoView = lazy(() => import("./pages/PrimeirosPassos/CategoriaLetramentoView"));
|
||||
const PrivacyPolicy = lazy(() =>
|
||||
import("./pages/PrivacyPolicy/PrivacyPolicy").then(m => ({
|
||||
default: m.PrivacyPolicy
|
||||
}))
|
||||
);
|
||||
|
||||
//Atividades
|
||||
const AspiradorGame = lazy(() => import("./atividades/programacao/aspirador/AspiradorGame"));
|
||||
@@ -61,13 +160,15 @@ const LoadingFallback = () => (
|
||||
);
|
||||
|
||||
// Separated so we can call useLocation (requires being inside Router)
|
||||
function AppRoutes() {
|
||||
function AppRoutes({ analyticsReady = false }) {
|
||||
const location = useLocation();
|
||||
// When navigating to a letramento category, the caller passes
|
||||
// { state: { backgroundLocation: location } } so the previous page
|
||||
// keeps rendering behind the modal overlay.
|
||||
const backgroundLocation = location.state?.backgroundLocation;
|
||||
|
||||
usePageTracking(analyticsReady);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
@@ -79,6 +180,7 @@ function AppRoutes() {
|
||||
<Route path="/laboratorio-python" element={<LabPython />} />
|
||||
<Route path="/sobre" element={<About />} />
|
||||
<Route path="/faq" element={<Faq />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
<Route path="/atividades" element={<Atividades />} />
|
||||
<Route path="/educadores" element={<Educadores />} />
|
||||
<Route path="/iniciativas" element={<Iniciativas />} />
|
||||
@@ -107,11 +209,26 @@ function AppRoutes() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [analyticsReady, setAnalyticsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasConsent = ConsentManager.hasConsent();
|
||||
initializeAnalytics({
|
||||
providerType: analyticsConfig.providerType,
|
||||
measurementId: analyticsConfig.measurementId,
|
||||
hasConsent,
|
||||
debugMode: analyticsConfig.debugMode,
|
||||
});
|
||||
|
||||
setAnalyticsReady(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppRoutes />
|
||||
<AppRoutes analyticsReady={analyticsReady} />
|
||||
</Suspense>
|
||||
<CookieBanner />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user