From 80c473e4396dd1c4009c409b2b07b1e1655d4b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:24:09 +0200 Subject: [PATCH] feat(mobile): add complete consent flow (banner, health modal, privacy, i18n) --- apps/frontend-mobile/app/(tabs)/profile.tsx | 8 ++ apps/frontend-mobile/app/_layout.tsx | 21 ++++- apps/frontend-mobile/app/privacy.tsx | 44 +++++++++ apps/frontend-mobile/app/scanner.tsx | 34 ++++++- .../components/CookieBanner.tsx | 92 +++++++++++++++++++ .../components/HealthConsentModal.tsx | 44 +++++++++ apps/frontend-mobile/services/consent.ts | 59 ++++++++++++ apps/frontend-mobile/src/i18n/locales/ca.js | 25 +++++ apps/frontend-mobile/src/i18n/locales/es.js | 25 +++++ 9 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 apps/frontend-mobile/app/privacy.tsx create mode 100644 apps/frontend-mobile/components/CookieBanner.tsx create mode 100644 apps/frontend-mobile/components/HealthConsentModal.tsx create mode 100644 apps/frontend-mobile/services/consent.ts diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index bef1f9e..25ea312 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -379,6 +379,14 @@ export default function ProfileScreen() { )} + + router.push('/privacy')}> + + + + {t('nav.privacy')} + + {/* Search history */} diff --git a/apps/frontend-mobile/app/_layout.tsx b/apps/frontend-mobile/app/_layout.tsx index 3283cf9..8007a13 100644 --- a/apps/frontend-mobile/app/_layout.tsx +++ b/apps/frontend-mobile/app/_layout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { Image, StyleSheet, View } from 'react-native'; @@ -10,6 +10,8 @@ import { registerForPushNotifications, addNotificationListener, addNotificationR import { ThemeProvider, useThemeContext } from '../components/ThemeProvider'; import { LanguageProvider, useTranslation } from '../src/i18n'; import { initFaro } from '../services/faro'; +import { hasConsentChoice } from '../services/consent'; +import CookieBanner from '../components/CookieBanner'; // Boot Faro RUM once, as early as possible. initFaro(); @@ -23,6 +25,7 @@ function RootLayoutInner() { const { checkAuth } = useAuthStore(); const { colors, isDark } = useThemeContext(); const { t } = useTranslation(); + const [showCookieBanner, setShowCookieBanner] = useState(false); const notificationListener = useRef>(); const responseListener = useRef>(); @@ -31,6 +34,11 @@ function RootLayoutInner() { registerForPushNotifications(); + (async () => { + const consentChoice = await hasConsentChoice(); + if (!consentChoice) setShowCookieBanner(true); + })(); + notificationListener.current = addNotificationListener((notification) => { console.log('Notification received:', notification); }); @@ -85,7 +93,18 @@ function RootLayoutInner() { headerShown: false, }} /> + + {showCookieBanner && ( + { + const { saveConsents } = await import('../services/consent'); + await saveConsents(consents); + setShowCookieBanner(false); + }} + onPrivacyPress={() => setShowCookieBanner(false)} + /> + )} ); diff --git a/apps/frontend-mobile/app/privacy.tsx b/apps/frontend-mobile/app/privacy.tsx new file mode 100644 index 0000000..49c6d9c --- /dev/null +++ b/apps/frontend-mobile/app/privacy.tsx @@ -0,0 +1,44 @@ +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 ( + + + router.back()} style={[styles.backBtn, { backgroundColor: colors.card }]}> + + + {t('privacy.title')} + + {t('privacy.last_updated')} + {SECTIONS.map(section => ( + + {t(`privacy.section.${section}.title`)} + {t(`privacy.section.${section}.content`)} + + ))} + + ); +} + +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 }, +}); diff --git a/apps/frontend-mobile/app/scanner.tsx b/apps/frontend-mobile/app/scanner.tsx index 02a786b..3f4490a 100644 --- a/apps/frontend-mobile/app/scanner.tsx +++ b/apps/frontend-mobile/app/scanner.tsx @@ -3,12 +3,16 @@ import { View, StyleSheet } from 'react-native'; import { useRouter } from 'expo-router'; import { BarcodeScanner } from '../components/BarcodeScanner'; import { searchMedicines } from '../services/medicines'; +import { hasConsent } from '../services/consent'; +import HealthConsentModal from '../components/HealthConsentModal'; export default function ScannerScreen() { const router = useRouter(); const [isSearching, setIsSearching] = useState(false); + const [showHealthConsent, setShowHealthConsent] = useState(false); + const [pendingBarcode, setPendingBarcode] = useState(null); - const handleBarcodeScanned = async (barcode: string) => { + const processBarcode = async (barcode: string) => { setIsSearching(true); try { const results = await searchMedicines(barcode); @@ -25,6 +29,16 @@ export default function ScannerScreen() { } }; + const handleBarcodeScanned = async (barcode: string) => { + const healthConsent = await hasConsent('health_data'); + if (!healthConsent) { + setPendingBarcode(barcode); + setShowHealthConsent(true); + return; + } + await processBarcode(barcode); + }; + const handleClose = () => { router.back(); }; @@ -35,6 +49,24 @@ export default function ScannerScreen() { onBarcodeScanned={handleBarcodeScanned} onClose={handleClose} /> + {showHealthConsent && ( + { + setShowHealthConsent(false); + if (pendingBarcode) { + const { saveConsents, getLocalConsents } = await import('../services/consent'); + const current = await getLocalConsents(); + await saveConsents({ ...current, health_data: true }); + await processBarcode(pendingBarcode); + setPendingBarcode(null); + } + }} + onCancel={() => { + setShowHealthConsent(false); + setPendingBarcode(null); + }} + /> + )} ); } diff --git a/apps/frontend-mobile/components/CookieBanner.tsx b/apps/frontend-mobile/components/CookieBanner.tsx new file mode 100644 index 0000000..aeceb2a --- /dev/null +++ b/apps/frontend-mobile/components/CookieBanner.tsx @@ -0,0 +1,92 @@ +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 ( + + + + + {t('cookie_banner.title')} + {t('cookie_banner.description')} + + + + {t('cookie_banner.category.essential')} + {t('cookie_banner.category.essential_desc')} + + + ON + + + {categoryKeys.map(cat => ( + + + {t(`cookie_banner.category.${cat}`)} + {t(`cookie_banner.category.${cat}_desc`)} + + toggleCategory(cat)} activeOpacity={0.7}> + + + + ))} + + + onConsent({ essential: true, analytics: true, preferences: true, health_data: true })}> + {t('cookie_banner.accept_all')} + + onConsent({ essential: true, analytics: false, preferences: false, health_data: false })}> + {t('cookie_banner.reject_optional')} + + onConsent({ essential: true, ...categories })}> + {t('cookie_banner.save')} + + + + {t('cookie_banner.more_info')} → + + + + + + ); +} + +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' }, +}); diff --git a/apps/frontend-mobile/components/HealthConsentModal.tsx b/apps/frontend-mobile/components/HealthConsentModal.tsx new file mode 100644 index 0000000..c4cad4a --- /dev/null +++ b/apps/frontend-mobile/components/HealthConsentModal.tsx @@ -0,0 +1,44 @@ +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 ( + + + + {t('health_consent.title')} + {t('health_consent.description')} + + + {t('health_consent.accept')} + + + {t('health_consent.cancel')} + + + + + + ); +} + +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' }, +}); diff --git a/apps/frontend-mobile/services/consent.ts b/apps/frontend-mobile/services/consent.ts new file mode 100644 index 0000000..d72e365 --- /dev/null +++ b/apps/frontend-mobile/services/consent.ts @@ -0,0 +1,59 @@ +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 { + 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 { + try { await SecureStore.setItemAsync(CONSENT_KEY, JSON.stringify(consents)); } catch {} +} + +export async function hasConsentChoice(): Promise { + try { return (await SecureStore.getItemAsync(CONSENT_KEY)) !== null; } catch { return false; } +} + +export async function fetchServerConsents(): Promise { + try { + const res = await api.get('/consents'); + await setLocalConsents(res.data); + return res.data; + } catch { return null; } +} + +export async function saveConsents(consents: Consents): Promise { + 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 { + const consents = await getLocalConsents(); + return consents[category] === true; +} diff --git a/apps/frontend-mobile/src/i18n/locales/ca.js b/apps/frontend-mobile/src/i18n/locales/ca.js index 72ba31f..92535d2 100644 --- a/apps/frontend-mobile/src/i18n/locales/ca.js +++ b/apps/frontend-mobile/src/i18n/locales/ca.js @@ -230,6 +230,31 @@ const ca = { 'barcodeScanner.cancel': 'Cancel·lar', 'barcodeScanner.scanningHint': 'Apunteu la càmera al codi de barres del medicament', 'barcodeScanner.scanAgain': 'Escanejar de nou', + + // 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', }; export default ca; diff --git a/apps/frontend-mobile/src/i18n/locales/es.js b/apps/frontend-mobile/src/i18n/locales/es.js index 36fc34f..5fe60c3 100644 --- a/apps/frontend-mobile/src/i18n/locales/es.js +++ b/apps/frontend-mobile/src/i18n/locales/es.js @@ -230,6 +230,31 @@ const es = { 'barcodeScanner.cancel': 'Cancelar', 'barcodeScanner.scanningHint': 'Apunta la cámara al código de barras del medicamento', 'barcodeScanner.scanAgain': 'Escanear de nuevo', + + // 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', }; export default es;