feat(i18n): add bilingual support (Català / Castellano)
Run Tests on Branches / Detect Changes (push) Successful in 11s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 1m44s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / Detect Changes (push) Successful in 11s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 1m44s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Add lightweight i18n system using React Context + useTranslation hook.
No external dependencies. Language persisted in localStorage('ff-lang').
New files:
- src/i18n/locales/ca.js (~410 translation keys)
- src/i18n/locales/es.js (~410 translation keys)
- src/i18n/LanguageContext.jsx (Provider + t() function)
- src/i18n/useTranslation.js (hook)
- src/i18n/index.js (barrel export)
Modified 22 files:
- main.jsx: wrap App with LanguageProvider
- App.test.jsx: add LanguageProvider wrapper for tests
- 13 user components: BottomNav, HomeView, LoginModal, SearchView,
ProfileView, AlertsView, ScannerView, PharmacyList, MedicineResults,
ProductResults, SavedNotifications, ErrorBoundary, SearchBar
- 6 admin components: AdminView, LoginForm, PharmacyManagement,
MedicineManagement, PharmacyMedicineLink, PharmacyProductLink
ProfileView includes language selector (globe icon + modal CA/ES)
placed next to the Theme selector.
No routes changed. No API calls affected. No navigation changes.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { BrowserMultiFormatReader } from '@zxing/browser';
|
||||
@@ -25,6 +26,7 @@ function playBeep() {
|
||||
}
|
||||
|
||||
function ScannerView({ onClose, onSelectMedicine }) {
|
||||
const { t } = useTranslation();
|
||||
const videoRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
@@ -78,7 +80,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
try {
|
||||
const supported = await BarcodeScanner.isSupported();
|
||||
if (!supported) {
|
||||
setErrorMsg('El escáner no está disponible en este dispositivo.');
|
||||
setErrorMsg(t('scanner.scannerUnavailable'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +89,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
if (permission.camera !== 'granted') {
|
||||
const request = await BarcodeScanner.requestPermissions();
|
||||
if (request.camera !== 'granted') {
|
||||
setErrorMsg('Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.');
|
||||
setErrorMsg(t('scanner.cameraPermissionDenied'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -104,7 +106,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
const rawValue = barcode?.rawValue;
|
||||
|
||||
if (!rawValue || !CIP_REGEX.test(rawValue)) {
|
||||
setErrorMsg('Código de barras inválido. Intenta de nuevo o introduce el CIP manualmente.');
|
||||
setErrorMsg(t('scanner.invalidBarcode'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +120,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
setErrorMsg(`Error al escanear: ${err.message || 'Error desconocido'}`);
|
||||
setErrorMsg(`${t('scanner.scanError')} ${err.message || t('scanner.unknownError')}`);
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
@@ -128,7 +130,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
console.log('[Scanner] Iniciando escaneo web...');
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setErrorMsg('Cámara no disponible en este navegador.');
|
||||
setErrorMsg(t('scanner.cameraNotAvailable'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -206,11 +208,11 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
console.error('[Scanner] Error general:', err);
|
||||
stopCamera();
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
|
||||
setErrorMsg(t('scanner.cameraPermissionWeb'));
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setErrorMsg('No se detectó ninguna cámara. Introduce el código CIP manualmente.');
|
||||
setErrorMsg(t('scanner.noCamera'));
|
||||
} else {
|
||||
setErrorMsg(`Error de cámara: ${err.message || 'Error desconocido'}`);
|
||||
setErrorMsg(`${t('scanner.cameraError')} ${err.message || t('scanner.unknownError')}`);
|
||||
}
|
||||
setPhase('error');
|
||||
}
|
||||
@@ -228,12 +230,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
e.preventDefault();
|
||||
const cip = manualCip.trim();
|
||||
if (!cip) {
|
||||
setErrorMsg('Introduce un código CIP.');
|
||||
setErrorMsg(t('scanner.enterCIPError'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
if (!CIP_REGEX.test(cip)) {
|
||||
setErrorMsg('Formato CIP inválido. Debe tener 16 caracteres alfanuméricos.');
|
||||
setErrorMsg(t('scanner.invalidCIP'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -268,14 +270,14 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
const data = await ocrRes.json();
|
||||
|
||||
if (!ocrRes.ok) {
|
||||
setErrorMsg(data.error || 'No se pudo leer la imagen. Intenta con otra foto.');
|
||||
setErrorMsg(data.error || t('scanner.imageReadError'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const cip = data.cip;
|
||||
if (!CIP_REGEX.test(cip)) {
|
||||
setErrorMsg(`CIP detectado "${cip}" no tiene formato válido. Introduce el código manualmente.`);
|
||||
setErrorMsg(t('scanner.invalidCIPDetected'));
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -286,7 +288,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
setPhase('prescriptions');
|
||||
fetchPrescriptions(cip);
|
||||
} catch (err) {
|
||||
setErrorMsg(`Error al procesar la imagen: ${err.message || 'Error desconocido'}`);
|
||||
setErrorMsg(`${t('scanner.imageProcessError')} ${err.message || t('scanner.unknownError')}`);
|
||||
setPhase('error');
|
||||
} finally {
|
||||
setOcrLoading(false);
|
||||
@@ -330,8 +332,8 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<path d="M7 8v8M11 8v8M15 8v8M19 8v8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="scanner-heading">Escanear TSI</h2>
|
||||
<p className="scanner-desc">Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas</p>
|
||||
<h2 className="scanner-heading">{t('scanner.title')}</h2>
|
||||
<p className="scanner-desc">{t('scanner.description')}</p>
|
||||
</div>
|
||||
|
||||
{phase !== 'prescriptions' && (
|
||||
@@ -343,7 +345,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
</svg>
|
||||
<p className="scan-error-text">{errorMsg}</p>
|
||||
<button className="scan-btn scan-btn--outline" onClick={() => { setErrorMsg(''); setPhase('idle'); }}>
|
||||
Intentar de nuevo
|
||||
{t('scanner.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -355,18 +357,18 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
Abrir cámara
|
||||
{t('scanner.openCamera')}
|
||||
</button>
|
||||
|
||||
<div className="upload-section">
|
||||
<label className="upload-label">O sube una foto de tu TSI</label>
|
||||
<label className="upload-label">{t('scanner.orUploadPhoto')}</label>
|
||||
<div className="upload-row">
|
||||
<button className="upload-option" onClick={() => cameraInputRef.current?.click()}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
<span>Hacer foto</span>
|
||||
<span>{t('scanner.takePhoto')}</span>
|
||||
</button>
|
||||
<button className="upload-option" onClick={() => galleryInputRef.current?.click()}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -374,7 +376,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
<span>Subir de galería</span>
|
||||
<span>{t('scanner.uploadFromGallery')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -405,14 +407,14 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<div className="corner bl" />
|
||||
<div className="corner br" />
|
||||
</div>
|
||||
<p className="scanner-hint">Apunta al código de barras</p>
|
||||
<p className="scanner-hint">{t('scanner.scanning')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && isNative && (
|
||||
<div className="scanning-active">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Abriendo cámara…</p>
|
||||
<p>{t('scanner.openingCamera')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -424,7 +426,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
{ocrLoading ? (
|
||||
<div className="scanning-active">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Procesando imagen…</p>
|
||||
<p>{t('scanner.processingImage')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="photo-preview-actions">
|
||||
@@ -434,14 +436,14 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<line x1="9" y1="20" x2="15" y2="20" />
|
||||
<line x1="12" y1="4" x2="12" y2="20" />
|
||||
</svg>
|
||||
Escanear imagen
|
||||
{t('scanner.scanImage')}
|
||||
</button>
|
||||
<button className="scan-btn scan-btn--ghost" onClick={handlePhotoDiscard}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
Descartar
|
||||
{t('scanner.discard')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -449,20 +451,20 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
)}
|
||||
|
||||
<form className="cip-form" onSubmit={handleManualSubmit}>
|
||||
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
|
||||
<label className="cip-label" htmlFor="cip-input">{t('scanner.enterCIP')}</label>
|
||||
<div className="cip-row">
|
||||
<input
|
||||
id="cip-input"
|
||||
className="cip-input"
|
||||
type="text"
|
||||
placeholder="Código CIP de 16 dígitos"
|
||||
placeholder={t('scanner.cipPlaceholder')}
|
||||
value={manualCip}
|
||||
onChange={(e) => setManualCip(e.target.value)}
|
||||
maxLength={16}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="scan-btn scan-btn--primary">Buscar</button>
|
||||
<button type="submit" className="scan-btn scan-btn--primary">{t('scanner.search')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
@@ -475,23 +477,23 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm3 10c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="cip-badge-label">TSI Escaneada</p>
|
||||
<p className="cip-badge-label">{t('scanner.tsiScanned')}</p>
|
||||
<p className="cip-badge-value">{scannedCip}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="rx-title">Recetas Activas</h3>
|
||||
<p className="rx-subtitle">Toca un medicamento para ver disponibilidad en farmacias cercanas.</p>
|
||||
<h3 className="rx-title">{t('scanner.activePrescriptions')}</h3>
|
||||
<p className="rx-subtitle">{t('scanner.tapToFind')}</p>
|
||||
|
||||
{loadingPrescriptions && (
|
||||
<div className="rx-loading">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Cargando recetas…</p>
|
||||
<p>{t('scanner.loadingPrescriptions')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingPrescriptions && prescriptions.length === 0 && (
|
||||
<p className="rx-empty">No se encontraron recetas activas para esta tarjeta.</p>
|
||||
<p className="rx-empty">{t('scanner.noPrescriptions')}</p>
|
||||
)}
|
||||
|
||||
<ul className="rx-list">
|
||||
@@ -523,7 +525,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
Escanear otra tarjeta
|
||||
{t('scanner.scanAnother')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user