Files
FarmaFinder/docs/superpowers/plans/2026-08-26-cookies-banner-privacy.md
T
2026-08-26 13:20:46 +02:00

1563 lines
62 KiB
Markdown

# Cookies Banner & Privacy Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add GDPR/RGPD-compliant cookie consent management, health data consent flow, and privacy policy page to FarmaFinder across web and mobile.
**Architecture:** Server-side consent storage with client sync. Consent stored in `user_consents` table (PG + SQLite), cached in localStorage (web) / expo-secure-store (mobile). Backend API for CRUD, middleware for enforcement. Cookie banner with 4 categories, health consent modal for TSI scanning, privacy policy page.
**Tech Stack:** Express.js (backend), React + Vite (web), Expo + React Native (mobile), PostgreSQL/SQLite, i18n (es/ca)
---
## File Map
### Backend (apps/backend/server.js)
- Add `user_consents` table creation in `initDatabase()`
- Add `GET /api/consents` and `PUT /api/consents` endpoints
- Add `requireConsent(category)` middleware
- Add consent migration logic on login/register
### Web Frontend (apps/frontend/src/)
- Create `components/CookieBanner.jsx` + `CookieBanner.css`
- Create `components/HealthConsentModal.jsx` + `HealthConsentModal.css`
- Create `views/PrivacyView.jsx` + `PrivacyView.css`
- Create `utils/consent.js` (consent sync utility)
- Modify `App.jsx` — add `privacy` screen, render CookieBanner, gate HealthConsent
- Modify `main.jsx` — gate Faro init on analytics consent
- Modify `views/ScannerView.jsx` — gate TSI scan on health_data consent
- Modify `i18n/locales/es.js` — add consent/privacy keys
- Modify `i18n/locales/ca.js` — add consent/privacy keys
### Mobile Frontend (apps/frontend-mobile/)
- Create `components/CookieBanner.tsx`
- Create `components/HealthConsentModal.tsx`
- Create `app/privacy.tsx`
- Create `services/consent.ts` (consent sync utility)
- Modify `app/_layout.tsx` — add privacy screen to Stack
- Modify `app/scanner.tsx` — gate TSI scan on health_data consent
- Modify `src/i18n/locales/es.js` — add consent/privacy keys
- Modify `src/i18n/locales/ca.js` — add consent/privacy keys
---
## Phase 1: Backend + Consent Infrastructure
### Task 1: Create user_consents table
**Files:**
- Modify: `apps/backend/server.js` (in `initDatabase()` function)
- [ ] **Step 1: Add PostgreSQL table creation**
Find the last `CREATE TABLE IF NOT EXISTS` block in `initDatabase()` (the `password_reset_tokens` table around line 627). After it, add:
```js
// ========== USER CONSENTS ==========
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS user_consents (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
session_id VARCHAR(255),
category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')),
granted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await pgPool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat
ON user_consents(user_id, category) WHERE user_id IS NOT NULL
`);
await pgPool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat
ON user_consents(session_id, category) WHERE session_id IS NOT NULL
`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL`);
}
```
- [ ] **Step 2: Add SQLite table creation**
After the PostgreSQL block, add the SQLite equivalent:
```js
if (!pgPool) {
await dbRun(`
CREATE TABLE IF NOT EXISTS user_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
session_id TEXT,
category TEXT NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')),
granted INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
try {
await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat ON user_consents(user_id, category) WHERE user_id IS NOT NULL`);
} catch (e) { if (!/duplicate/i.test(e.message)) throw e; }
try {
await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat ON user_consents(session_id, category) WHERE session_id IS NOT NULL`);
} catch (e) { if (!/duplicate/i.test(e.message)) throw e; }
}
```
- [ ] **Step 3: Verify server starts**
Run: `cd apps/backend && node --check server.js`
Expected: No syntax errors.
- [ ] **Step 4: Commit**
```bash
git add apps/backend/server.js
git commit -m "feat(backend): add user_consents table schema"
```
---
### Task 2: Add consent API endpoints
**Files:**
- Modify: `apps/backend/server.js` (add routes after `requireAdmin` middleware, around line 912)
- [ ] **Step 1: Add requireConsent middleware**
After the `requireAdmin` middleware, add:
```js
// Middleware to check if user has granted a specific consent category
const requireConsent = (category) => {
return async (req, res, next) => {
try {
const userId = req.session?.userId;
const sessionId = req.sessionID;
let consent;
if (userId) {
consent = await userDbGet('SELECT granted FROM user_consents WHERE user_id = ? AND category = ?', [userId, category]);
} else if (sessionId) {
consent = await userDbGet('SELECT granted FROM user_consents WHERE session_id = ? AND category = ?', [sessionId, category]);
}
if (consent && consent.granted) return next();
return res.status(403).json({ error: `Consent required: ${category}` });
} catch (error) {
console.error('Consent check error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
};
```
- [ ] **Step 2: Add GET /api/consents endpoint**
```js
// ========== CONSENT MANAGEMENT ==========
app.get('/api/consents', async (req, res) => {
try {
const userId = req.session?.userId;
const sessionId = req.sessionID;
const categories = ['essential', 'analytics', 'preferences', 'health_data'];
const result = {};
categories.forEach(c => { result[c] = c === 'essential'; });
if (userId) {
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]);
rows.forEach(r => { result[r.category] = r.granted; });
} else if (sessionId) {
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]);
rows.forEach(r => { result[r.category] = r.granted; });
}
result.essential = true;
res.json(result);
} catch (error) {
console.error('Error fetching consents:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
```
- [ ] **Step 3: Add PUT /api/consents endpoint**
```js
app.put('/api/consents', async (req, res) => {
try {
const userId = req.session?.userId;
const sessionId = req.sessionID;
const { categories } = req.body;
if (!categories || typeof categories !== 'object') {
return res.status(400).json({ error: 'categories object required' });
}
const allowedCategories = ['analytics', 'preferences', 'health_data'];
for (const cat of allowedCategories) {
if (cat in categories) {
const granted = Boolean(categories[cat]);
if (userId) {
await userDbRun(
`INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`,
[userId, cat, granted, granted]
);
} else if (sessionId) {
await userDbRun(
`INSERT INTO user_consents (session_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (session_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`,
[sessionId, cat, granted, granted]
);
}
}
}
// Return updated consents
const result = { essential: true };
const cats = ['analytics', 'preferences', 'health_data'];
if (userId) {
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]);
rows.forEach(r => { result[r.category] = r.granted; });
} else if (sessionId) {
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]);
rows.forEach(r => { result[r.category] = r.granted; });
}
cats.forEach(c => { if (!(c in result)) result[c] = false; });
res.json(result);
} catch (error) {
console.error('Error saving consents:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
```
- [ ] **Step 4: Verify syntax**
Run: `node --check apps/backend/server.js`
Expected: No output (no errors).
- [ ] **Step 5: Commit**
```bash
git add apps/backend/server.js
git commit -m "feat(backend): add GET/PUT /api/consents endpoints and requireConsent middleware"
```
---
### Task 3: Add consent migration on login/register
**Files:**
- Modify: `apps/backend/server.js`
- [ ] **Step 1: Add consent migration helper**
After the consent endpoints, add:
```js
async function migrateSessionConsents(userId, sessionId) {
if (!userId || !sessionId) return;
try {
const sessionConsents = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]);
for (const consent of sessionConsents) {
await userDbRun(
`INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`,
[userId, consent.category, consent.granted, consent.granted]
);
}
await userDbRun('DELETE FROM user_consents WHERE session_id = ?', [sessionId]);
} catch (error) {
console.error('Error migrating consents:', error);
}
}
```
- [ ] **Step 2: Add migration call to login route**
In `POST /api/auth/login`, after `req.session.userId = user.id`, add:
```js
await migrateSessionConsents(user.id, req.sessionID);
```
- [ ] **Step 3: Add migration call to register route**
In `POST /api/auth/register`, after the userId is assigned to the session, add:
```js
await migrateSessionConsents(userId, req.sessionID);
```
Note: Find the exact variable name for userId in the register route.
- [ ] **Step 4: Verify syntax**
Run: `node --check apps/backend/server.js`
- [ ] **Step 5: Commit**
```bash
git add apps/backend/server.js
git commit -m "feat(backend): migrate anonymous consents on login/register"
```
---
## Phase 2: Web Frontend
### Task 4: Add i18n keys for consent and privacy (web)
**Files:**
- Modify: `apps/frontend/src/i18n/locales/es.js`
- Modify: `apps/frontend/src/i18n/locales/ca.js`
- [ ] **Step 1: Add Spanish i18n keys**
Open `apps/frontend/src/i18n/locales/es.js`. Add the following keys before the closing `};` and `export default es;`:
```js
// Cookie Banner
'cookie_banner.title': 'Utilizamos cookies y datos personales',
'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria para buscar tus recetas.',
'cookie_banner.category.essential': 'Esenciales',
'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app. No se pueden desactivar.',
'cookie_banner.category.analytics': 'Analítica',
'cookie_banner.category.analytics_desc': 'Nos ayudan a entender cómo se usa la app para mejorarla.',
'cookie_banner.category.preferences': 'Preferencias',
'cookie_banner.category.preferences_desc': 'Recordar tu idioma, tema y búsquedas guardadas.',
'cookie_banner.category.health_data': 'Datos de salud',
'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria (TSI) para buscar recetas y medicamentos.',
'cookie_banner.accept_all': 'Aceptar todo',
'cookie_banner.reject_optional': 'Rechazar opcionales',
'cookie_banner.save': 'Guardar',
'cookie_banner.more_info': 'Más información',
// Health Consent Modal
'health_consent.title': 'Consentimiento para datos de salud',
'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas del sistema sanitario. Esta información se usa únicamente para buscarte los medicamentos de tus recetas.',
'health_consent.accept': 'Aceptar y escanear',
'health_consent.cancel': 'Cancelar',
// Privacy View
'privacy.title': 'Política de Privacidad',
'privacy.last_updated': 'Última actualización: 26/08/2026',
'privacy.section.controller.title': 'Responsable del Tratamiento',
'privacy.section.controller.content': 'Hacecalor S.L. es la responsable del tratamiento de tus datos personales.',
'privacy.section.data_collected.title': 'Datos Recopilados',
'privacy.section.data_collected.content': 'Recopilamos los siguientes datos:\n• Datos de perfil: nombre de usuario, email, nombre, apellidos, ciudad, avatar\n• Direcciones: dirección física con coordenadas geográficas\n• Ubicación: ubicación en tiempo real para ordenar farmacias por distancia\n• Historial de búsquedas: medicamentos y ubicaciones buscadas\n• Datos de salud: código CIP de tu tarjeta sanitaria y recetas asociadas\n• Tokens de notificaciones: para enviarte avisos de disponibilidad de medicamentos\n• Datos de sesión: identificador de sesión para mantenerte conectado',
'privacy.section.purpose.title': 'Finalidad del Tratamiento',
'privacy.section.purpose.content': 'Tus datos se utilizan para:\n• Buscar medicamentos y farmacias cercanas\n• Gestionar tu cuenta y preferencias\n• Enviarte notificaciones de disponibilidad de medicamentos\n• Escanear tu tarjeta sanitaria para encontrar tus recetas\n• Mejorar la aplicación mediante análisis de uso',
'privacy.section.legal_basis.title': 'Base Legal',
'privacy.section.legal_basis.content': 'El tratamiento se basa en:\n• Consentimiento explícito: datos de salud (tarjeta sanitaria) y análisis de uso\n• Ejecución de contrato: gestión de tu cuenta y servicios solicitados\n• Interés legítimo: seguridad de la aplicación y prevención de fraude',
'privacy.section.external_services.title': 'Servicios Externos',
'privacy.section.external_services.content': 'Utilizamos los siguientes servicios externos:\n• CIMA (Agencia Española de Medicamentos): base de datos de medicamentos\n• Grafana Faro: análisis de uso y rendimiento de la aplicación\n• Nominatim/OpenStreetMap: geolocalización de farmacias\n• N8N: automatización de procesos internos\n• Proveedor de email: envío de emails de recuperación de contraseña',
'privacy.section.retention.title': 'Conservación de Datos',
'privacy.section.retention.content': '• Sesiones: 24 horas\n• Cuenta de usuario: hasta que solicites su eliminación\n• Registros de consentimiento: 3 años\n• Historial de búsquedas: 6 meses\n• Datos de ubicación: se eliminan al cerrar la sesión',
'privacy.section.rights.title': 'Tus Derechos',
'privacy.section.rights.content': 'Tienes derecho a:\n• Acceder a tus datos personales\n• Rectificar datos inexactos\n• Solicitar la eliminación de tus datos\n• Portabilidad de datos\n• Oponerte al tratamiento\n• Retirar tu consentimiento en cualquier momento\n\nPara ejercer estos derechos, contacta con nosotros en la dirección indicada abajo.',
'privacy.section.contact.title': 'Contacto',
'privacy.section.contact.content': 'Para ejercer tus derechos o consultar sobre el tratamiento de tus datos, contacta con Hacecalor S.L. a través de la dirección de email de soporte de la aplicación.',
'privacy.section.cookies.title': 'Política de Cookies',
'privacy.section.cookies.content': 'Utilizamos las siguientes categorías de cookies:\n• Esenciales: necesarias para el funcionamiento de la app (sesión, autenticación)\n• Analítica: para medir el uso y rendimiento de la app\n• Preferencias: para recordar tu idioma, tema y configuración\n• Datos de salud: para el escaneo de tarjeta sanitaria y recetas\n\nPuedes gestionar tus preferencias de cookies en cualquier momento desde la configuración de la aplicación.',
'nav.privacy': 'Política de privacidad',
```
- [ ] **Step 2: Add Catalan i18n keys**
Open `apps/frontend/src/i18n/locales/ca.js`. Add the following keys before the closing `};` and `export default ca;`:
```js
// Cookie Banner
'cookie_banner.title': 'Utilitzem cookies i dades personals',
'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència, analitzar l\'ús de l\'aplicació i, si ho permetes, escanejar la teva targeta sanitària per buscar les teves receptes.',
'cookie_banner.category.essential': 'Essencials',
'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'aplicació. No es poden desactivar.',
'cookie_banner.category.analytics': 'Analítica',
'cookie_banner.category.analytics_desc': 'Ens ajuden a entendre com s\'usa l\'aplicació per millorar-la.',
'cookie_banner.category.preferences': 'Preferències',
'cookie_banner.category.preferences_desc': 'Recordar el teu idioma, tema i cerques guardades.',
'cookie_banner.category.health_data': 'Dades de salut',
'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària (TSI) per buscar receptes i medicaments.',
'cookie_banner.accept_all': 'Acceptar tot',
'cookie_banner.reject_optional': 'Rebutjar opcionals',
'cookie_banner.save': 'Desar',
'cookie_banner.more_info': 'Més informació',
// Health Consent Modal
'health_consent.title': 'Consentiment per a dades de salut',
'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes del sistema sanitari. Aquesta informació s\'usa únicament per buscar-te els medicaments de les teves receptes.',
'health_consent.accept': 'Acceptar i escanejar',
'health_consent.cancel': 'Cancel·lar',
// Privacy View
'privacy.title': 'Política de Privacitat',
'privacy.last_updated': 'Última actualització: 26/08/2026',
'privacy.section.controller.title': 'Responsable del Tractament',
'privacy.section.controller.content': 'Hacecalor S.L. és la responsable del tractament de les teves dades personals.',
'privacy.section.data_collected.title': 'Dades Recollides',
'privacy.section.data_collected.content': 'Recollim les següents dades:\n• Dades de perfil: nom d\'usuari, email, nom, cognoms, ciutat, avatar\n• Adreces: adreça física amb coordenades geogràfiques\n• Ubicació: ubicació en temps real per ordenar farmàcies per distància\n• Historial de cerques: medicaments i ubicacions buscats\n• Dades de salut: codi CIP de la teva targeta sanitària i receptes associades\n• Tokens de notificacions: per enviar-te avisos de disponibilitat de medicaments\n• Dades de sessió: identificador de sessió per mantenir-te connectat',
'privacy.section.purpose.title': 'Finalitat del Tractament',
'privacy.section.purpose.content': 'Les teves dades s\'utilitzen per:\n• Buscar medicaments i farmàcies properes\n• Gestionar el teu compte i preferències\n• Enviar-te notificacions de disponibilitat de medicaments\n• Escanejar la teva targeta sanitària per trobar les teves receptes\n• Millorar l\'aplicació mitjançant l\'anàlisi d\'ús',
'privacy.section.legal_basis.title': 'Base Legal',
'privacy.section.legal_basis.content': 'El tractament es basa en:\n• Consentiment explícit: dades de salut (targeta sanitària) i anàlisi d\'ús\n• Execució de contracte: gestió del teu compte i serveis sol·licitats\n• Interès legítim: seguretat de l\'aplicació i prevenció de frau',
'privacy.section.external_services.title': 'Serveis Externs',
'privacy.section.external_services.content': 'Utilitzem els següents serveis externs:\n• CIMA (Agència Espanyola de Medicaments): base de dades de medicaments\n• Grafana Faro: anàlisi d\'ús i rendiment de l\'aplicació\n• Nominatim/OpenStreetMap: geolocalització de farmàcies\n• N8N: automatització de processos interns\n• Proveïdor d\'email: enviament d\'emails de recuperació de contrasenya',
'privacy.section.retention.title': 'Conservació de Dades',
'privacy.section.retention.content': '• Sessones: 24 hores\n• Compte d\'usuari: fins que sol·licitis la seva eliminació\n• Registres de consentiment: 3 anys\n• Historial de cerques: 6 mesos\n• Dades d\'ubicació: s\'eliminen en tancar la sessió',
'privacy.section.rights.title': 'Els Teus Drets',
'privacy.section.rights.content': 'Tens dret a:\n• Accedir a les teves dades personals\n• Rectificar dades inexactes\n• Sol·licitar l\'eliminació de les teves dades\n• Portabilitat de dades\n• Oposar-te al tractament\n• Retirar el teu consentiment en qualsevol moment\n\nPer exercir aquests drets, contacta amb nosaltres a l\'adreça indicada a continuació.',
'privacy.section.contact.title': 'Contacte',
'privacy.section.contact.content': 'Per exercir els teus drets o consultar sobre el tractament de les teves dades, contacta amb Hacecalor S.L. a través de l\'adreça de email de suport de l\'aplicació.',
'privacy.section.cookies.title': 'Política de Cookies',
'privacy.section.cookies.content': 'Utilitzem les següents categories de cookies:\n• Essencials: necessàries per al funcionament de l\'app (sessió, autenticació)\n• Analítica: per mesurar l\'ús i rendiment de l\'app\n• Preferències: per recordar el teu idioma, tema i configuració\n• Dades de salut: per a l\'escaneig de targeta sanitària i receptes\n\nPots gestionar les teves preferències de cookies en qualsevol moment des de la configuració de l\'aplicació.',
'nav.privacy': 'Política de privacitat',
```
- [ ] **Step 3: Verify syntax**
Run: `node --check apps/frontend/src/i18n/locales/es.js`
Expected: No syntax errors.
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/i18n/locales/es.js apps/frontend/src/i18n/locales/ca.js
git commit -m "feat(i18n): add consent and privacy keys for web (es+ca)"
```
---
### Task 5: Create consent sync utility (web)
**Files:**
- Create: `apps/frontend/src/utils/consent.js`
- [ ] **Step 1: Create the consent utility**
```js
const CONSENT_KEY = 'farmafinder_consents';
const DEFAULT_CONSENTS = {
essential: true,
analytics: false,
preferences: false,
health_data: false,
};
export function getLocalConsents() {
try {
const stored = localStorage.getItem(CONSENT_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_CONSENTS, ...parsed, essential: true };
}
} catch {}
return { ...DEFAULT_CONSENTS };
}
export function setLocalConsents(consents) {
try {
localStorage.setItem(CONSENT_KEY, JSON.stringify(consents));
} catch {}
}
export function hasConsentChoice() {
try {
return localStorage.getItem(CONSENT_KEY) !== null;
} catch {
return false;
}
}
export async function fetchServerConsents() {
try {
const res = await fetch('/api/consents', { credentials: 'include' });
if (res.ok) {
const serverConsents = await res.json();
setLocalConsents(serverConsents);
return serverConsents;
}
} catch {}
return null;
}
export async function saveConsents(consents) {
const toSave = { ...consents, essential: true };
setLocalConsents(toSave);
try {
const res = await fetch('/api/consents', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
categories: {
analytics: toSave.analytics,
preferences: toSave.preferences,
health_data: toSave.health_data,
},
}),
});
if (res.ok) {
const serverConsents = await res.json();
setLocalConsents(serverConsents);
return serverConsents;
}
} catch {}
return toSave;
}
export function hasConsent(category) {
const consents = getLocalConsents();
return consents[category] === true;
}
```
- [ ] **Step 2: Verify syntax**
Run: `node --check apps/frontend/src/utils/consent.js`
- [ ] **Step 3: Commit**
```bash
git add apps/frontend/src/utils/consent.js
git commit -m "feat(web): add consent sync utility"
```
---
### Task 6: Create CookieBanner component (web)
**Files:**
- Create: `apps/frontend/src/components/CookieBanner.jsx`
- Create: `apps/frontend/src/components/CookieBanner.css`
- [ ] **Step 1: Create CookieBanner.jsx**
```jsx
import React, { useState } from 'react';
import { useTranslation } from '../i18n';
import './CookieBanner.css';
function CookieBanner({ onConsent, onPrivacyClick }) {
const { t } = useTranslation();
const [categories, setCategories] = useState({
analytics: false,
preferences: false,
health_data: false,
});
function toggleCategory(cat) {
setCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
}
function handleAcceptAll() {
onConsent({ essential: true, analytics: true, preferences: true, health_data: true });
}
function handleRejectOptional() {
onConsent({ essential: true, analytics: false, preferences: false, health_data: false });
}
function handleSave() {
onConsent({ essential: true, ...categories });
}
return (
<div className="cookie-banner-overlay">
<div className="cookie-banner">
<h2 className="cookie-banner-title">{t('cookie_banner.title')}</h2>
<p className="cookie-banner-desc">{t('cookie_banner.description')}</p>
<div className="cookie-categories">
<div className="cookie-category">
<div className="cookie-category-info">
<span className="cookie-category-name">{t('cookie_banner.category.essential')}</span>
<span className="cookie-category-desc">{t('cookie_banner.category.essential_desc')}</span>
</div>
<div className="cookie-toggle cookie-toggle--locked">
<span className="cookie-toggle-track"><span className="cookie-toggle-thumb" /></span>
</div>
</div>
{['analytics', 'preferences', 'health_data'].map(cat => (
<div className="cookie-category" key={cat}>
<div className="cookie-category-info">
<span className="cookie-category-name">{t(`cookie_banner.category.${cat}`)}</span>
<span className="cookie-category-desc">{t(`cookie_banner.category.${cat}_desc`)}</span>
</div>
<button type="button" className={`cookie-toggle ${categories[cat] ? 'cookie-toggle--on' : ''}`} onClick={() => toggleCategory(cat)} aria-pressed={categories[cat]}>
<span className="cookie-toggle-track"><span className="cookie-toggle-thumb" /></span>
</button>
</div>
))}
</div>
<div className="cookie-banner-actions">
<button type="button" className="cookie-btn cookie-btn--primary" onClick={handleAcceptAll}>{t('cookie_banner.accept_all')}</button>
<button type="button" className="cookie-btn cookie-btn--secondary" onClick={handleRejectOptional}>{t('cookie_banner.reject_optional')}</button>
<button type="button" className="cookie-btn cookie-btn--tertiary" onClick={handleSave}>{t('cookie_banner.save')}</button>
</div>
<button type="button" className="cookie-more-info" onClick={onPrivacyClick}>{t('cookie_banner.more_info')} →</button>
</div>
</div>
);
}
export default CookieBanner;
```
- [ ] **Step 2: Create CookieBanner.css**
```css
.cookie-banner-overlay {
position: fixed;
inset: 0;
background: rgba(28, 25, 23, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease;
}
.cookie-banner {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: 1.5rem 1.25rem 1.25rem;
width: 100%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.15);
animation: slideUp 0.25s ease;
}
.cookie-banner-title { font-size: 1.1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.5rem; }
.cookie-banner-desc { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; margin: 0 0 1rem; }
.cookie-categories { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem; }
.cookie-category { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem; background: var(--surface-muted); border-radius: var(--radius); }
.cookie-category-info { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; }
.cookie-category-name { font-size: 0.9rem; font-weight: 600; color: var(--text-main); }
.cookie-category-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; }
.cookie-toggle { background: none; border: none; cursor: pointer; padding: 0; }
.cookie-toggle-track { display: block; width: 44px; height: 24px; background: var(--border); border-radius: 12px; position: relative; transition: background 0.2s; }
.cookie-toggle--on .cookie-toggle-track { background: var(--primary); }
.cookie-toggle-thumb { display: block; width: 20px; height: 20px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: transform 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); }
.cookie-toggle--on .cookie-toggle-thumb { transform: translateX(20px); }
.cookie-toggle--locked .cookie-toggle-track { background: var(--primary); opacity: 0.6; cursor: not-allowed; }
.cookie-banner-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.cookie-btn { flex: 1; min-width: 0; padding: 0.6rem 1rem; border-radius: 999px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; white-space: nowrap; }
.cookie-btn:hover { opacity: 0.85; }
.cookie-btn--primary { background: var(--primary); color: var(--on-primary); }
.cookie-btn--secondary { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); }
.cookie-btn--tertiary { background: transparent; color: var(--primary); border: 1px solid var(--primary); }
.cookie-more-info { display: block; width: 100%; margin-top: 0.75rem; padding: 0; background: none; border: none; color: var(--text-muted); font-size: 0.8rem; cursor: pointer; text-align: center; text-decoration: underline; }
.cookie-more-info:hover { color: var(--text-main); }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (min-width: 769px) {
.cookie-banner-overlay { align-items: center; padding: 1rem; }
.cookie-banner { border-radius: var(--radius-lg); max-width: 420px; }
}
```
- [ ] **Step 3: Commit**
```bash
git add apps/frontend/src/components/CookieBanner.jsx apps/frontend/src/components/CookieBanner.css
git commit -m "feat(web): add CookieBanner component"
```
---
### Task 7: Create HealthConsentModal component (web)
**Files:**
- Create: `apps/frontend/src/components/HealthConsentModal.jsx`
- Create: `apps/frontend/src/components/HealthConsentModal.css`
- [ ] **Step 1: Create HealthConsentModal.jsx**
```jsx
import React, { useEffect } from 'react';
import { useTranslation } from '../i18n';
import './HealthConsentModal.css';
function HealthConsentModal({ onAccept, onCancel }) {
const { t } = useTranslation();
useEffect(() => {
function handleKey(e) { if (e.key === 'Escape') onCancel(); }
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onCancel]);
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="health-consent-modal" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={t('health_consent.title')}>
<h2 className="health-consent-title">{t('health_consent.title')}</h2>
<p className="health-consent-desc">{t('health_consent.description')}</p>
<div className="health-consent-actions">
<button type="button" className="health-consent-btn health-consent-btn--accept" onClick={onAccept}>{t('health_consent.accept')}</button>
<button type="button" className="health-consent-btn health-consent-btn--cancel" onClick={onCancel}>{t('health_consent.cancel')}</button>
</div>
</div>
</div>
);
}
export default HealthConsentModal;
```
- [ ] **Step 2: Create HealthConsentModal.css**
```css
.health-consent-modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem 2.25rem 1.75rem; width: 100%; max-width: 360px; box-shadow: 0 20px 60px rgba(28, 25, 23, 0.18); animation: slideUp 0.18s ease; }
.health-consent-title { font-size: 1.15rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; }
.health-consent-desc { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; margin: 0 0 1.5rem; }
.health-consent-actions { display: flex; flex-direction: column; gap: 0.5rem; }
.health-consent-btn { width: 100%; padding: 0.65rem 1rem; border-radius: 999px; border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
.health-consent-btn:hover { opacity: 0.85; }
.health-consent-btn--accept { background: var(--primary); color: var(--on-primary); }
.health-consent-btn--cancel { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); }
```
- [ ] **Step 3: Commit**
```bash
git add apps/frontend/src/components/HealthConsentModal.jsx apps/frontend/src/components/HealthConsentModal.css
git commit -m "feat(web): add HealthConsentModal component"
```
---
### Task 8: Create PrivacyView component (web)
**Files:**
- Create: `apps/frontend/src/views/PrivacyView.jsx`
- Create: `apps/frontend/src/views/PrivacyView.css`
- [ ] **Step 1: Create PrivacyView.jsx**
```jsx
import React from 'react';
import { useTranslation } from '../i18n';
import './PrivacyView.css';
function PrivacyView({ onBack }) {
const { t } = useTranslation();
const sections = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'];
return (
<div className="privacy-view">
<div className="privacy-header">
<button type="button" className="privacy-back" onClick={onBack}>←</button>
<h1 className="privacy-title">{t('privacy.title')}</h1>
</div>
<div className="privacy-content">
<p className="privacy-updated">{t('privacy.last_updated')}</p>
{sections.map(section => (
<section key={section} className="privacy-section">
<h2 className="privacy-section-title">{t(`privacy.section.${section}.title`)}</h2>
<div className="privacy-section-content">
{t(`privacy.section.${section}.content`).split('\n').map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</section>
))}
</div>
</div>
);
}
export default PrivacyView;
```
- [ ] **Step 2: Create PrivacyView.css**
```css
.privacy-view { width: 100%; max-width: 640px; margin: 0 auto; padding: 1rem 1.25rem 2rem; animation: fadeInUp 0.3s ease-out; }
.privacy-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; }
.privacy-back { background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius); width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1rem; color: var(--text-main); flex-shrink: 0; transition: background 0.15s; }
.privacy-back:hover { background: var(--border); }
.privacy-title { font-size: 1.3rem; font-weight: 700; color: var(--text-main); margin: 0; }
.privacy-updated { font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1.5rem; }
.privacy-content { display: flex; flex-direction: column; gap: 1.5rem; }
.privacy-section { background: var(--surface-muted); border-radius: var(--radius); padding: 1.25rem; }
.privacy-section-title { font-size: 1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; }
.privacy-section-content { font-size: 0.88rem; color: var(--text-muted); line-height: 1.6; }
.privacy-section-content p { margin: 0 0 0.5rem; }
.privacy-section-content p:last-child { margin-bottom: 0; }
@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 768px) { .privacy-view { padding: 0.75rem 1rem 2rem; } }
```
- [ ] **Step 3: Commit**
```bash
git add apps/frontend/src/views/PrivacyView.jsx apps/frontend/src/views/PrivacyView.css
git commit -m "feat(web): add PrivacyView component"
```
---
### Task 9: Integrate consent into App.jsx (web)
**Files:**
- Modify: `apps/frontend/src/App.jsx`
- [ ] **Step 1: Add imports**
At the top of `App.jsx`, add:
```js
import CookieBanner from './components/CookieBanner';
import HealthConsentModal from './components/HealthConsentModal';
import PrivacyView from './views/PrivacyView';
import { hasConsentChoice, getLocalConsents, saveConsents, fetchServerConsents } from './utils/consent';
```
- [ ] **Step 2: Add consent state**
After the existing `useState` declarations, add:
```js
const [showCookieBanner, setShowCookieBanner] = useState(!hasConsentChoice());
const [showHealthConsent, setShowHealthConsent] = useState(false);
const [pendingTsiScan, setPendingTsiScan] = useState(null);
const [consents, setConsents] = useState(getLocalConsents);
```
- [ ] **Step 3: Add consent sync effect**
After the existing `useEffect` blocks, add:
```js
useEffect(() => {
if (currentUser) {
fetchServerConsents().then(serverConsents => {
if (serverConsents) setConsents(serverConsents);
});
}
}, [currentUser]);
```
- [ ] **Step 4: Add consent handlers**
```js
async function handleCookieConsent(newConsents) {
const saved = await saveConsents(newConsents);
setConsents(saved);
setShowCookieBanner(false);
}
function handleTsiScanRequest(scanFn) {
if (consents.health_data) {
scanFn();
} else {
setPendingTsiScan(() => scanFn);
setShowHealthConsent(true);
}
}
function handleHealthConsentAccept() {
saveConsents({ ...consents, health_data: true });
setConsents(prev => ({ ...prev, health_data: true }));
setShowHealthConsent(false);
if (pendingTsiScan) {
pendingTsiScan();
setPendingTsiScan(null);
}
}
function handleHealthConsentCancel() {
setShowHealthConsent(false);
setPendingTsiScan(null);
}
```
- [ ] **Step 5: Add privacy screen to routing**
In `switch (screen)`, add before the `default:` case:
```js
case 'privacy':
activeView = <PrivacyView onBack={() => setScreen('home')} />;
break;
```
- [ ] **Step 6: Pass consent props to ScannerView**
In `case 'scan':`, add props to ScannerView:
```jsx
<ScannerView
onClose={() => setScreen('home')}
onSelectMedicine={(name) => { setPrescriptionSearch(name); setScreen('search'); }}
onTsiScanRequest={handleTsiScanRequest}
consents={consents}
/>
```
- [ ] **Step 7: Render CookieBanner and HealthConsentModal**
In the JSX return, after the `{showSaved && ...}` line and before the closing `</div>`, add:
```jsx
{showCookieBanner && (
<CookieBanner
onConsent={handleCookieConsent}
onPrivacyClick={() => { setShowCookieBanner(false); setScreen('privacy'); }}
/>
)}
{showHealthConsent && (
<HealthConsentModal onAccept={handleHealthConsentAccept} onCancel={handleHealthConsentCancel} />
)}
```
- [ ] **Step 8: Verify syntax**
Run: `node --check apps/frontend/src/App.jsx`
- [ ] **Step 9: Commit**
```bash
git add apps/frontend/src/App.jsx
git commit -m "feat(web): integrate consent flow into App.jsx"
```
---
### Task 10: Gate Faro initialization on analytics consent (web)
**Files:**
- Modify: `apps/frontend/src/main.jsx`
- [ ] **Step 1: Modify Faro init call**
In `main.jsx`, replace `initFaro();` with:
```js
try {
const consents = JSON.parse(localStorage.getItem('farmafinder_consents') || '{}');
if (consents.analytics) {
initFaro();
}
} catch {}
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend/src/main.jsx
git commit -m "feat(web): gate Grafana Faro on analytics consent"
```
---
### Task 11: Gate TSI scanner on health_data consent (web)
**Files:**
- Modify: `apps/frontend/src/views/ScannerView.jsx`
- [ ] **Step 1: Add new props**
Change the component signature to:
```js
function ScannerView({ onClose, onSelectMedicine, onTsiScanRequest, consents }) {
```
- [ ] **Step 2: Modify handleStartScan**
Replace the existing `handleStartScan`:
```js
function handleStartScan() {
const doScan = () => {
if (isNative) { handleNativeScan(); } else { handleWebScan(); }
};
if (onTsiScanRequest) {
onTsiScanRequest(doScan);
} else {
doScan();
}
}
```
- [ ] **Step 3: Commit**
```bash
git add apps/frontend/src/views/ScannerView.jsx
git commit -m "feat(web): gate TSI scanner on health_data consent"
```
---
### Task 12: Add privacy link to BottomNav (web)
**Files:**
- Modify: `apps/frontend/src/components/BottomNav.jsx`
- Modify: `apps/frontend/src/components/BottomNav.css`
- Modify: `apps/frontend/src/App.jsx` (handleNavChange)
- [ ] **Step 1: Add privacy link to BottomNav.jsx**
After the tabs map and before closing `</nav>`, add:
```jsx
<button type="button" className="bottom-nav-privacy" onClick={() => onChange('privacy')}>
{t('nav.privacy')}
</button>
```
- [ ] **Step 2: Add CSS for privacy link**
In `BottomNav.css`, add:
```css
.bottom-nav-privacy { display: block; width: 100%; text-align: center; padding: 0.35rem 0 0; background: none; border: none; color: var(--text-muted); font-size: 0.7rem; cursor: pointer; text-decoration: underline; }
.bottom-nav-privacy:hover { color: var(--text-main); }
```
- [ ] **Step 3: Handle 'privacy' tab in App.jsx**
In `handleNavChange`, add:
```js
if (tab === 'privacy') { setScreen('privacy'); return; }
```
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/components/BottomNav.jsx apps/frontend/src/components/BottomNav.css apps/frontend/src/App.jsx
git commit -m "feat(web): add privacy link to BottomNav"
```
---
## Phase 3: Mobile Frontend
### Task 13: Add i18n keys for consent and privacy (mobile)
**Files:**
- Modify: `apps/frontend-mobile/src/i18n/locales/es.js`
- Modify: `apps/frontend-mobile/src/i18n/locales/ca.js`
- [ ] **Step 1: Add Spanish i18n keys**
Open `apps/frontend-mobile/src/i18n/locales/es.js`. Add before the closing `};`:
```js
// Cookie Banner
'cookie_banner.title': 'Utilizamos cookies y datos personales',
'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria.',
'cookie_banner.category.essential': 'Esenciales',
'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app.',
'cookie_banner.category.analytics': 'Analítica',
'cookie_banner.category.analytics_desc': 'Nos ayudan a mejorar la app.',
'cookie_banner.category.preferences': 'Preferencias',
'cookie_banner.category.preferences_desc': 'Recordar tu idioma y tema.',
'cookie_banner.category.health_data': 'Datos de salud',
'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria.',
'cookie_banner.accept_all': 'Aceptar todo',
'cookie_banner.reject_optional': 'Rechazar',
'cookie_banner.save': 'Guardar',
'cookie_banner.more_info': 'Más información',
// Health Consent
'health_consent.title': 'Consentimiento para datos de salud',
'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas.',
'health_consent.accept': 'Aceptar y escanear',
'health_consent.cancel': 'Cancelar',
// Privacy
'privacy.title': 'Política de Privacidad',
'privacy.last_updated': 'Última actualización: 26/08/2026',
'nav.privacy': 'Política de privacidad',
```
- [ ] **Step 2: Add Catalan i18n keys**
Open `apps/frontend-mobile/src/i18n/locales/ca.js`. Add before the closing `};`:
```js
// Cookie Banner
'cookie_banner.title': 'Utilitzem cookies i dades personals',
'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència i, si ho permetes, escanejar la teva targeta sanitària.',
'cookie_banner.category.essential': 'Essencials',
'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'app.',
'cookie_banner.category.analytics': 'Analítica',
'cookie_banner.category.analytics_desc': 'Ens ajuden a millorar l\'app.',
'cookie_banner.category.preferences': 'Preferències',
'cookie_banner.category.preferences_desc': 'Recordar el teu idioma i tema.',
'cookie_banner.category.health_data': 'Dades de salut',
'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària.',
'cookie_banner.accept_all': 'Acceptar tot',
'cookie_banner.reject_optional': 'Rebutjar',
'cookie_banner.save': 'Desar',
'cookie_banner.more_info': 'Més informació',
// Health Consent
'health_consent.title': 'Consentiment per a dades de salut',
'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes.',
'health_consent.accept': 'Acceptar i escanejar',
'health_consent.cancel': 'Cancel·lar',
// Privacy
'privacy.title': 'Política de Privacitat',
'privacy.last_updated': 'Última actualització: 26/08/2026',
'nav.privacy': 'Política de privacitat',
```
- [ ] **Step 3: Verify syntax**
Run: `node --check apps/frontend-mobile/src/i18n/locales/es.js`
- [ ] **Step 4: Commit**
```bash
git add apps/frontend-mobile/src/i18n/locales/es.js apps/frontend-mobile/src/i18n/locales/ca.js
git commit -m "feat(mobile): add consent and privacy i18n keys (es+ca)"
```
---
### Task 14: Create consent sync utility (mobile)
**Files:**
- Create: `apps/frontend-mobile/services/consent.ts`
- [ ] **Step 1: Create consent.ts**
```ts
import * as SecureStore from 'expo-secure-store';
import { api } from './api';
const CONSENT_KEY = 'consents';
export interface Consents {
essential: boolean;
analytics: boolean;
preferences: boolean;
health_data: boolean;
}
const DEFAULT_CONSENTS: Consents = {
essential: true,
analytics: false,
preferences: false,
health_data: false,
};
export async function getLocalConsents(): Promise<Consents> {
try {
const stored = await SecureStore.getItemAsync(CONSENT_KEY);
if (stored) return { ...DEFAULT_CONSENTS, ...JSON.parse(stored), essential: true };
} catch {}
return { ...DEFAULT_CONSENTS };
}
export async function setLocalConsents(consents: Consents): Promise<void> {
try { await SecureStore.setItemAsync(CONSENT_KEY, JSON.stringify(consents)); } catch {}
}
export async function hasConsentChoice(): Promise<boolean> {
try { return (await SecureStore.getItemAsync(CONSENT_KEY)) !== null; } catch { return false; }
}
export async function fetchServerConsents(): Promise<Consents | null> {
try {
const res = await api.get('/consents');
await setLocalConsents(res.data);
return res.data;
} catch { return null; }
}
export async function saveConsents(consents: Consents): Promise<Consents> {
const toSave = { ...consents, essential: true };
await setLocalConsents(toSave);
try {
const res = await api.put('/consents', {
categories: { analytics: toSave.analytics, preferences: toSave.preferences, health_data: toSave.health_data },
});
await setLocalConsents(res.data);
return res.data;
} catch { return toSave; }
}
export async function hasConsent(category: keyof Consents): Promise<boolean> {
const consents = await getLocalConsents();
return consents[category] === true;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend-mobile/services/consent.ts
git commit -m "feat(mobile): add consent sync utility"
```
---
### Task 15: Create CookieBanner component (mobile)
**Files:**
- Create: `apps/frontend-mobile/components/CookieBanner.tsx`
- [ ] **Step 1: Create CookieBanner.tsx**
```tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal } from 'react-native';
import { useThemeContext } from './ThemeProvider';
import { useTranslation } from '../src/i18n';
import { borderRadius } from '../constants/theme';
interface CookieBannerProps {
onConsent: (consents: { essential: boolean; analytics: boolean; preferences: boolean; health_data: boolean }) => void;
onPrivacyPress: () => void;
}
export default function CookieBanner({ onConsent, onPrivacyPress }: CookieBannerProps) {
const { colors } = useThemeContext();
const { t } = useTranslation();
const [categories, setCategories] = useState({ analytics: false, preferences: false, health_data: false });
const toggleCategory = (cat: keyof typeof categories) => {
setCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
};
const categoryKeys = ['analytics', 'preferences', 'health_data'] as const;
return (
<Modal transparent animationType="slide" visible>
<View style={styles.overlay}>
<View style={[styles.container, { backgroundColor: colors.card }]}>
<ScrollView showsVerticalScrollIndicator={false}>
<Text style={[styles.title, { color: colors.text }]}>{t('cookie_banner.title')}</Text>
<Text style={[styles.desc, { color: colors.textSecondary }]}>{t('cookie_banner.description')}</Text>
<View style={styles.categories}>
<View style={[styles.category, { backgroundColor: colors.background }]}>
<View style={styles.categoryInfo}>
<Text style={[styles.categoryName, { color: colors.text }]}>{t('cookie_banner.category.essential')}</Text>
<Text style={[styles.categoryDesc, { color: colors.textSecondary }]}>{t('cookie_banner.category.essential_desc')}</Text>
</View>
<View style={[styles.toggle, styles.toggleLocked, { backgroundColor: colors.primary }]}>
<Text style={styles.toggleLabel}>ON</Text>
</View>
</View>
{categoryKeys.map(cat => (
<View key={cat} style={[styles.category, { backgroundColor: colors.background }]}>
<View style={styles.categoryInfo}>
<Text style={[styles.categoryName, { color: colors.text }]}>{t(`cookie_banner.category.${cat}`)}</Text>
<Text style={[styles.categoryDesc, { color: colors.textSecondary }]}>{t(`cookie_banner.category.${cat}_desc`)}</Text>
</View>
<TouchableOpacity style={[styles.toggle, categories[cat] && { backgroundColor: colors.primary }]} onPress={() => toggleCategory(cat)} activeOpacity={0.7}>
<View style={[styles.toggleThumb, categories[cat] && styles.toggleThumbOn]} />
</TouchableOpacity>
</View>
))}
</View>
<View style={styles.actions}>
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.primary }]} onPress={() => onConsent({ essential: true, analytics: true, preferences: true, health_data: true })}>
<Text style={[styles.btnText, { color: colors.background }]}>{t('cookie_banner.accept_all')}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.background, borderColor: colors.border, borderWidth: 1 }]} onPress={() => onConsent({ essential: true, analytics: false, preferences: false, health_data: false })}>
<Text style={[styles.btnText, { color: colors.text }]}>{t('cookie_banner.reject_optional')}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, { borderColor: colors.primary, borderWidth: 1 }]} onPress={() => onConsent({ essential: true, ...categories })}>
<Text style={[styles.btnText, { color: colors.primary }]}>{t('cookie_banner.save')}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity onPress={onPrivacyPress}>
<Text style={[styles.moreInfo, { color: colors.textSecondary }]}>{t('cookie_banner.more_info')} →</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
container: { borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: 20, maxHeight: '85%' },
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
desc: { fontSize: 14, lineHeight: 20, marginBottom: 16 },
categories: { gap: 10, marginBottom: 16 },
category: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: borderRadius.md, gap: 12 },
categoryInfo: { flex: 1, gap: 2 },
categoryName: { fontSize: 14, fontWeight: '600' },
categoryDesc: { fontSize: 12, lineHeight: 16 },
toggle: { width: 44, height: 24, borderRadius: 12, backgroundColor: '#ccc', justifyContent: 'center', alignItems: 'center', padding: 2 },
toggleLocked: { opacity: 0.6 },
toggleLabel: { fontSize: 9, fontWeight: '700', color: 'white' },
toggleThumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: 'white', alignSelf: 'flex-start' },
toggleThumbOn: { alignSelf: 'flex-end' },
actions: { gap: 8, marginBottom: 12 },
btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' },
btnText: { fontSize: 14, fontWeight: '600' },
moreInfo: { textAlign: 'center', fontSize: 12, textDecorationLine: 'underline' },
});
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend-mobile/components/CookieBanner.tsx
git commit -m "feat(mobile): add CookieBanner component"
```
---
### Task 16: Create HealthConsentModal component (mobile)
**Files:**
- Create: `apps/frontend-mobile/components/HealthConsentModal.tsx`
- [ ] **Step 1: Create HealthConsentModal.tsx**
```tsx
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { useThemeContext } from './ThemeProvider';
import { useTranslation } from '../src/i18n';
import { borderRadius } from '../constants/theme';
interface HealthConsentModalProps {
onAccept: () => void;
onCancel: () => void;
}
export default function HealthConsentModal({ onAccept, onCancel }: HealthConsentModalProps) {
const { colors } = useThemeContext();
const { t } = useTranslation();
return (
<Modal transparent animationType="fade" visible>
<View style={styles.overlay}>
<View style={[styles.container, { backgroundColor: colors.card }]}>
<Text style={[styles.title, { color: colors.text }]}>{t('health_consent.title')}</Text>
<Text style={[styles.desc, { color: colors.textSecondary }]}>{t('health_consent.description')}</Text>
<View style={styles.actions}>
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.primary }]} onPress={onAccept}>
<Text style={[styles.btnText, { color: colors.background }]}>{t('health_consent.accept')}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border }]} onPress={onCancel}>
<Text style={[styles.btnText, { color: colors.text }]}>{t('health_consent.cancel')}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: 24 },
container: { borderRadius: borderRadius.lg, padding: 24, width: '100%', maxWidth: 340 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
desc: { fontSize: 14, lineHeight: 20, marginBottom: 20 },
actions: { gap: 10 },
btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' },
btnText: { fontSize: 14, fontWeight: '600' },
});
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend-mobile/components/HealthConsentModal.tsx
git commit -m "feat(mobile): add HealthConsentModal component"
```
---
### Task 17: Create privacy screen (mobile)
**Files:**
- Create: `apps/frontend-mobile/app/privacy.tsx`
- [ ] **Step 1: Create privacy.tsx**
```tsx
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useThemeContext } from '../components/ThemeProvider';
import { useTranslation } from '../src/i18n';
import { spacing, borderRadius } from '../constants/theme';
const SECTIONS = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'] as const;
export default function PrivacyScreen() {
const router = useRouter();
const { colors } = useThemeContext();
const { t } = useTranslation();
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={[styles.backBtn, { backgroundColor: colors.card }]}>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{t('privacy.title')}</Text>
</View>
<Text style={[styles.updated, { color: colors.textSecondary }]}>{t('privacy.last_updated')}</Text>
{SECTIONS.map(section => (
<View key={section} style={[styles.section, { backgroundColor: colors.card }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t(`privacy.section.${section}.title`)}</Text>
<Text style={[styles.sectionContent, { color: colors.textSecondary }]}>{t(`privacy.section.${section}.content`)}</Text>
</View>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: spacing.md },
header: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 },
backBtn: { width: 36, height: 36, borderRadius: borderRadius.md, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 22, fontWeight: '700' },
updated: { fontSize: 12, marginBottom: 16 },
section: { borderRadius: borderRadius.md, padding: 16, marginBottom: 12 },
sectionTitle: { fontSize: 16, fontWeight: '700', marginBottom: 8 },
sectionContent: { fontSize: 14, lineHeight: 22 },
});
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend-mobile/app/privacy.tsx
git commit -m "feat(mobile): add privacy screen"
```
---
### Task 18: Integrate consent into mobile app layout
**Files:**
- Modify: `apps/frontend-mobile/app/_layout.tsx`
- [ ] **Step 1: Add privacy screen to Stack**
In `app/_layout.tsx`, add inside the `<Stack>` navigator:
```tsx
<Stack.Screen name="privacy" options={{ title: t('privacy.title') }} />
```
- [ ] **Step 2: Add consent imports and state**
Add imports:
```tsx
import { hasConsentChoice, getLocalConsents, fetchServerConsents } from '../services/consent';
```
In the `RootLayoutInner` component, add state:
```tsx
const [showCookieBanner, setShowCookieBanner] = useState(false);
```
- [ ] **Step 3: Add consent initialization**
In the existing `useEffect` (where `checkAuth()` is called), add:
```tsx
const consentChoice = await hasConsentChoice();
if (!consentChoice) setShowCookieBanner(true);
```
- [ ] **Step 4: Commit**
```bash
git add apps/frontend-mobile/app/_layout.tsx
git commit -m "feat(mobile): integrate consent flow into app layout"
```
---
### Task 19: Gate TSI scanner on health_data consent (mobile)
**Files:**
- Modify: `apps/frontend-mobile/app/scanner.tsx`
- [ ] **Step 1: Add consent check to scanner**
Import:
```tsx
import { hasConsent } from '../services/consent';
import HealthConsentModal from '../components/HealthConsentModal';
```
Add state:
```tsx
const [showHealthConsent, setShowHealthConsent] = useState(false);
const [pendingBarcode, setPendingBarcode] = useState<string | null>(null);
```
Wrap the barcode handler:
```tsx
const handleBarcodeScanned = async (barcode: string) => {
const healthConsent = await hasConsent('health_data');
if (!healthConsent) {
setPendingBarcode(barcode);
setShowHealthConsent(true);
return;
}
await processBarcode(barcode);
};
const processBarcode = async (barcode: string) => {
setIsSearching(true);
try {
const results = await searchMedicines(barcode);
if (results.length > 0) router.push(`/medicine/${results[0].nregistro}`);
else router.push(`/medicine/${barcode}`);
} catch { router.push(`/medicine/${barcode}`); }
finally { setIsSearching(false); }
};
```
Add health consent modal in JSX:
```tsx
{showHealthConsent && (
<HealthConsentModal
onAccept={async () => {
setShowHealthConsent(false);
if (pendingBarcode) await processBarcode(pendingBarcode);
setPendingBarcode(null);
}}
onCancel={() => { setShowHealthConsent(false); setPendingBarcode(null); }}
/>
)}
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend-mobile/app/scanner.tsx
git commit -m "feat(mobile): gate TSI scanner on health_data consent"
```
---
### Task 20: Add privacy link to mobile profile
**Files:**
- Modify: `apps/frontend-mobile/app/(tabs)/profile.tsx`
- [ ] **Step 1: Add privacy menu item**
In the profile screen's menu card (the section with Config, My Addresses, Admin), add a new item:
```tsx
<TouchableOpacity style={[styles.menuItem, { borderBottomColor: colors.border }]} onPress={() => router.push('/privacy')}>
<Ionicons name="shield-checkmark-outline" size={20} color={colors.primary} />
<Text style={[styles.menuText, { color: colors.text }]}>{t('nav.privacy')}</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
</TouchableOpacity>
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend-mobile/app/(tabs)/profile.tsx
git commit -m "feat(mobile): add privacy link to profile screen"
```