diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index 5ccc763..6d6caa6 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -634,5 +634,19 @@
"import": "Rows past your limit stay in the project but aren't matched."
}
}
+ },
+ "privacy_consent": {
+ "preferences": "Privacy preferences",
+ "banner_text": "We would like to use Hotjar heatmaps to see where people click and scroll in the Mapper, so we can improve it. What you type and the content you upload are masked. Nothing is collected unless you accept.",
+ "accept": "Accept",
+ "reject": "Reject",
+ "choose": "Choose",
+ "essential_title": "Essential",
+ "essential_text": "Sign-in, your language and this choice. The Mapper needs them to work.",
+ "always_on": "Always on",
+ "heatmaps_title": "Heatmaps (Hotjar)",
+ "heatmaps_text": "Clicks, taps, mouse movement and scrolling on Mapper pages, and a snapshot of the page layout (not video), so we can see what to improve. What you type and the content you upload are masked before anything leaves your browser.",
+ "reload_notice": "Hotjar is already running on this page. Saving reloads the page to stop it; your project is saved automatically.",
+ "save": "Save"
}
}
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index 4b01521..86dcded 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -634,5 +634,19 @@
"import": "Las filas que superan su límite permanecen en el proyecto pero no se emparejan."
}
}
+ },
+ "privacy_consent": {
+ "preferences": "Preferencias de privacidad",
+ "banner_text": "Nos gustaría usar mapas de calor de Hotjar para ver dónde hacen clic y desplazan las personas en el Mapper y así mejorarlo. Lo que escribes y el contenido que subes quedan ocultos. No se recopila nada si no aceptas.",
+ "accept": "Aceptar",
+ "reject": "Rechazar",
+ "choose": "Elegir",
+ "essential_title": "Esenciales",
+ "essential_text": "Inicio de sesión, tu idioma y esta elección. El Mapper los necesita para funcionar.",
+ "always_on": "Siempre activos",
+ "heatmaps_title": "Mapas de calor (Hotjar)",
+ "heatmaps_text": "Clics, toques, movimiento del ratón y desplazamiento en las páginas del Mapper, y una instantánea del diseño de la página (no es video), para saber qué mejorar. Lo que escribes y el contenido que subes se ocultan antes de salir de tu navegador.",
+ "reload_notice": "Hotjar ya está activo en esta página. Al guardar, la página se recarga para detenerlo; tu proyecto se guarda automáticamente.",
+ "save": "Guardar"
}
}
diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json
index 34a9a31..efc332c 100644
--- a/src/i18n/locales/pt-BR/translations.json
+++ b/src/i18n/locales/pt-BR/translations.json
@@ -634,5 +634,19 @@
"import": "Linhas além do seu limite permanecem no projeto mas não são correspondidas."
}
}
+ },
+ "privacy_consent": {
+ "preferences": "Preferências de privacidade",
+ "banner_text": "Gostaríamos de usar mapas de calor do Hotjar para ver onde as pessoas clicam e rolam no Mapper e assim melhorá-lo. O que você digita e o conteúdo que você envia ficam mascarados. Nada é coletado se você não aceitar.",
+ "accept": "Aceitar",
+ "reject": "Recusar",
+ "choose": "Escolher",
+ "essential_title": "Essenciais",
+ "essential_text": "Login, seu idioma e esta escolha. O Mapper precisa deles para funcionar.",
+ "always_on": "Sempre ativos",
+ "heatmaps_title": "Mapas de calor (Hotjar)",
+ "heatmaps_text": "Cliques, toques, movimento do mouse e rolagem nas páginas do Mapper, e um instantâneo do layout da página (não é vídeo), para sabermos o que melhorar. O que você digita e o conteúdo que você envia são mascarados antes de sair do seu navegador.",
+ "reload_notice": "O Hotjar já está ativo nesta página. Ao salvar, a página é recarregada para desativá-lo; seu projeto é salvo automaticamente.",
+ "save": "Salvar"
}
}
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index ec3b01a..60cb1d4 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -634,5 +634,19 @@
"import": "超出限制的行会保留在项目中,但不会被匹配。"
}
}
+ },
+ "privacy_consent": {
+ "preferences": "隐私偏好设置",
+ "banner_text": "我们希望使用 Hotjar 热图了解用户在 Mapper 中的点击和滚动位置,以便改进产品。您输入的内容和上传的内容都会被遮盖。除非您同意,否则不会收集任何数据。",
+ "accept": "接受",
+ "reject": "拒绝",
+ "choose": "自定义",
+ "essential_title": "必要",
+ "essential_text": "登录、您的语言和此项选择。Mapper 需要它们才能正常运行。",
+ "always_on": "始终开启",
+ "heatmaps_title": "热图(Hotjar)",
+ "heatmaps_text": "Mapper 页面上的点击、轻触、鼠标移动和滚动,以及页面布局的快照(不是视频),帮助我们了解需要改进的地方。您输入的内容和上传的内容在离开浏览器之前即被遮盖。",
+ "reload_notice": "Hotjar 已在此页面运行。保存后页面将重新加载以停止它;您的项目会自动保存。",
+ "save": "保存"
}
}
diff --git a/src/services/HotjarService.js b/src/services/HotjarService.js
new file mode 100644
index 0000000..5d404c9
--- /dev/null
+++ b/src/services/HotjarService.js
@@ -0,0 +1,106 @@
+/*eslint no-process-env: 0*/
+import reactHotjar from 'react-hotjar';
+import { isGranted, onConsentChange } from './consent.js';
+
+// A default import, so Node can load this CommonJS package in the unit tests too.
+const { hotjar } = reactHotjar;
+
+// Hotjar heatmaps on the Mapper (OpenConceptLab/ocl_online#533, chosen in #464).
+// Plan and requirements: docs/ocl_online-533-hotjar-heatmaps-plan.md.
+//
+// Hotjar loads only after the person allows the "heatmaps" category
+// (services/consent.js, components/app/PrivacyConsent.jsx). Before that it sets
+// no script, no cookie and no request.
+//
+// How Hotjar produces heatmaps (help.hotjar.com, "How to Set Up Heatmaps",
+// "How to Update a Heatmap Screenshot"): they come from *session capture*, the
+// same capture as Recordings. That is a DOM snapshot plus clicks, taps, moves and
+// scrolls, not video. The heatmap is drawn over a screenshot taken from a captured
+// session. Elements marked data-hj-suppress are masked in the browser before
+// anything is sent: the grid and right-hand panel in MapProject.jsx, the project
+// list in MapProjects.jsx, the user menu. Inputs are masked by default.
+//
+// Dashboard setup (site HOTJAR_ID 3169197, set in production env-config.js; leave
+// it unset everywhere else):
+// 1. Site settings > Session capture: on. The daily session limit is shared by
+// Heatmaps and Recordings; above it Hotjar samples.
+// 2. Site settings > Tracking URL changes: "Track changes manually", so Hotjar
+// uses the paths recordPageView sends instead of the real URL.
+// 3. Heatmaps, one per page, by URL filter ("is exactly" / "matches regex", PCRE2):
+// https://map.openconceptlab.org/ (home, project list)
+// https://map.openconceptlab.org/map-projects/new (new project)
+// ^https://map\.openconceptlab\.org/(users|orgs)/:owner/map-projects/:projectId/?$
+// 4. Recordings (optional): the same session capture under the same consent;
+// limit them to the three pages with URL targeting, see "How to trigger
+// Recordings and Heatmaps based on URLs" (article 36820033643281).
+
+const SNIPPET_VERSION = 6;
+
+/*eslint no-undef: 0*/
+const hotjarId = () => (typeof window !== 'undefined' && window.HOTJAR_ID) || process.env.HOTJAR_ID;
+
+// /users/jon/map-projects/42 -> /users/:owner/map-projects/:projectId. Each project
+// would otherwise get its own heatmap, keyed by a URL carrying the username.
+// Mirrors the route in App.jsx.
+const PROJECT_PATH = /^\/([^/]+)\/[^/]+\/map-projects\/[^/]+\/?$/;
+export const hotjarPath = pathname =>
+ pathname.replace(PROJECT_PATH, (_, ownerType) => `/${ownerType}/:owner/map-projects/:projectId`);
+
+// Hotjar's cookies (_hjSessionUser_, _hjSession_, ...) can sit on the
+// host or on a parent domain, so expire each one on all of them, never on the TLD.
+export const clearHotjarCookies = (doc = document) => {
+ const names = doc.cookie.split(';').map(cookie => cookie.split('=')[0].trim()).filter(name => name.startsWith('_hj'));
+ const labels = doc.location.hostname.split('.');
+ const domains = labels.slice(0, -1).map((_, i) => labels.slice(i).join('.'));
+ names.forEach(name => {
+ doc.cookie = `${name}=; Max-Age=0; path=/`;
+ domains.forEach(domain => { doc.cookie = `${name}=; Max-Age=0; path=/; domain=${domain}`; });
+ });
+};
+
+let running = false;
+let listening = false;
+
+const start = () => {
+ const id = hotjarId();
+ if(running || !id)
+ return;
+ hotjar.initialize(id, SNIPPET_VERSION);
+ running = true;
+ hotjar.stateChange(hotjarPath(window.location.pathname));
+};
+
+const HotjarService = {
+ // No HOTJAR_ID (dev, QA, staging): nothing to consent to, so PrivacyConsent hides
+ isConfigured() {
+ return Boolean(hotjarId());
+ },
+
+ // Once, at app start: starts Hotjar now if allowed, and whenever the choice
+ // later allows it. Hotjar has no off switch once its script runs, so a
+ // withdrawal clears its cookies and PrivacyConsent reloads the page.
+ initialize() {
+ if(isGranted('heatmaps'))
+ start();
+ if(listening)
+ return;
+ listening = true;
+ onConsentChange(consent => {
+ if(consent.heatmaps)
+ start();
+ else
+ clearHotjarCookies();
+ });
+ },
+
+ isRunning() {
+ return running;
+ },
+
+ recordPageView(pathname) {
+ if(running)
+ hotjar.stateChange(hotjarPath(pathname));
+ },
+};
+
+export default HotjarService;
diff --git a/src/services/consent.js b/src/services/consent.js
new file mode 100644
index 0000000..26d187b
--- /dev/null
+++ b/src/services/consent.js
@@ -0,0 +1,65 @@
+// The person's choice about optional tracking in the Mapper
+// (OpenConceptLab/ocl_online#533). Nothing optional loads until they choose:
+// no choice, an unreadable one, or one saved under another CONSENT_VERSION all
+// grant nothing.
+//
+// The choice is kept in this browser only. Another device, or clearing site data,
+// asks again. Bump CONSENT_VERSION when a category is added, or when what one
+// covers changes, so everyone is asked again.
+//
+// Essential storage (sign-in, language, this choice) needs no consent and is not
+// a category. GA4 (GAService) is not gated yet: see
+// docs/ocl_online-533-hotjar-heatmaps-plan.md, "Open decisions".
+
+export const CONSENT_KEY = 'privacyConsent'
+export const CONSENT_VERSION = 1
+
+// heatmaps: Hotjar heatmaps and the session capture they are built from (HotjarService)
+export const CATEGORIES = ['heatmaps']
+
+const defaultStorage = () => {
+ try {
+ return localStorage
+ } catch {
+ return undefined
+ }
+}
+
+export const getConsent = (storage = defaultStorage()) => {
+ try {
+ const consent = JSON.parse(storage?.getItem(CONSENT_KEY) || 'null')
+ return consent?.version === CONSENT_VERSION ? consent : null
+ } catch {
+ return null
+ }
+}
+
+export const hasDecided = (storage = defaultStorage()) => Boolean(getConsent(storage))
+
+export const isGranted = (category, storage = defaultStorage()) => getConsent(storage)?.[category] === true
+
+const listeners = new Set()
+
+// listener(consent, previous) after every save; returns the unsubscribe function
+export const onConsentChange = listener => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export const saveConsent = (choices, { storage = defaultStorage(), now = () => new Date() } = {}) => {
+ const previous = getConsent(storage)
+ const consent = { version: CONSENT_VERSION, decidedAt: now().toISOString() }
+ CATEGORIES.forEach(category => { consent[category] = choices?.[category] === true })
+ try {
+ storage?.setItem(CONSENT_KEY, JSON.stringify(consent))
+ } catch {
+ // storage unavailable (private mode, blocked cookies): the choice holds for this page only
+ }
+ listeners.forEach(listener => listener(consent, previous))
+ return consent
+}
+
+const allCategories = granted => Object.fromEntries(CATEGORIES.map(category => [category, granted]))
+
+export const acceptAll = options => saveConsent(allCategories(true), options)
+export const rejectAll = options => saveConsent(allCategories(false), options)