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;