diff --git a/frontend/package.json b/frontend/package.json index 206a727..6341394 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.22.0", "@stellar/freighter-api": "^2.0.0", - "@stellar/stellar-sdk": "^12.0.0" + "@stellar/stellar-sdk": "^12.0.0", + "i18next": "^23.11.5", + "react-i18next": "^14.1.2" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/src/components/LanguageSelector.jsx b/frontend/src/components/LanguageSelector.jsx new file mode 100644 index 0000000..698f65c --- /dev/null +++ b/frontend/src/components/LanguageSelector.jsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next'; + +const LANGUAGES = [ + { code: 'en', label: 'EN' }, + { code: 'fr', label: 'FR' }, +]; + +export default function LanguageSelector() { + const { i18n } = useTranslation(); + + const handleChange = (code) => { + i18n.changeLanguage(code); + localStorage.setItem('lang', code); + }; + + return ( +
+ {LANGUAGES.map(({ code, label }) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/NFTCard.jsx b/frontend/src/components/NFTCard.jsx index 1489ef1..a559bfa 100644 --- a/frontend/src/components/NFTCard.jsx +++ b/frontend/src/components/NFTCard.jsx @@ -1,6 +1,8 @@ import CopyButton from './CopyButton'; export default function NFTCard({ record, onClick }) { + const { t } = useTranslation(); + return (
{ - if (e.target === e.currentTarget) onClose(); - }; + if (!record) return null; - const handleKeyDown = (e) => { - if (e.key === 'Escape') onClose(); - }; + const handleOverlayClick = (e) => { if (e.target === e.currentTarget) onClose(); }; + const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); }; + const explorerUrl = record.tx_hash ? `${STELLAR_EXPERT_BASE}/${record.tx_hash}` : null; - const explorerUrl = record.tx_hash - ? `${STELLAR_EXPERT_BASE}/${record.tx_hash}` - : null; + const fields = [ + { label: t('modal.vaccineName'), value: record.vaccine_name }, + { label: t('modal.dateAdministered'), value: record.date_administered }, + { label: t('modal.tokenId'), value: `#${record.token_id}` }, + { label: t('modal.issuerAddress'), value: record.issuer }, + ...(record.tx_hash ? [{ label: t('modal.txHash'), value: record.tx_hash }] : []), + ]; return (
el?.focus()} >
- - +

- 💉 Vaccination Record + {t('modal.title')}

@@ -97,30 +88,19 @@ export default function RecordDetailModal({ record, onClose }) {

- )} - + ))}
{explorerUrl ? ( - View on Stellar Explorer ↗ + {t('modal.viewExplorer')} ) : ( -

- Transaction hash not available for this record. -

+

{t('modal.noTxHash')}

)}
diff --git a/frontend/src/components/VerificationBadge.jsx b/frontend/src/components/VerificationBadge.jsx index b57a957..671121c 100644 --- a/frontend/src/components/VerificationBadge.jsx +++ b/frontend/src/components/VerificationBadge.jsx @@ -1,58 +1,42 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; const LoadingSpinner = () => ( - - + ); export default function VerificationBadge({ status, vaccinated, recordCount = 0 }) { + const { t } = useTranslation(); + const configs = { verified: { - bg: 'rgba(22, 163, 74, 0.1)', - border: 'rgba(22, 163, 74, 0.2)', - color: '#16a34a', - label: `Verified: ${recordCount} Record${recordCount !== 1 ? 's' : ''}`, - icon: '✓' + bg: 'rgba(22, 163, 74, 0.1)', border: 'rgba(22, 163, 74, 0.2)', color: '#16a34a', + label: t('badge.verified', { count: recordCount }), + icon: '✓', }, 'not-found': { - bg: 'rgba(100, 116, 139, 0.1)', - border: 'rgba(100, 116, 139, 0.2)', - color: '#64748b', - label: 'No Records Found', - icon: '?' + bg: 'rgba(100, 116, 139, 0.1)', border: 'rgba(100, 116, 139, 0.2)', color: '#64748b', + label: t('badge.notFound'), + icon: '?', }, revoked: { - bg: 'rgba(220, 38, 38, 0.1)', - border: 'rgba(220, 38, 38, 0.2)', - color: '#dc2626', - label: 'Certificate Revoked', - icon: '✕' + bg: 'rgba(220, 38, 38, 0.1)', border: 'rgba(220, 38, 38, 0.2)', color: '#dc2626', + label: t('badge.revoked'), + icon: '✕', }, loading: { - bg: 'rgba(37, 99, 235, 0.1)', - border: 'rgba(37, 99, 235, 0.2)', - color: '#2563eb', - label: 'Verifying Status...', - icon: - } + bg: 'rgba(37, 99, 235, 0.1)', border: 'rgba(37, 99, 235, 0.2)', color: '#2563eb', + label: t('badge.verifying'), + icon: , + }, }; let effectiveStatus = status; @@ -62,33 +46,18 @@ export default function VerificationBadge({ status, vaccinated, recordCount = 0 const config = configs[effectiveStatus] || configs['not-found']; return ( -
- + {config.icon} {config.label} diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 0000000..846189d --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,13 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './locales/en.json'; +import fr from './locales/fr.json'; + +i18n.use(initReactI18next).init({ + resources: { en: { translation: en }, fr: { translation: fr } }, + lng: localStorage.getItem('lang') || 'en', + fallbackLng: 'en', + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..3507886 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,79 @@ +{ + "nav": { + "home": "Home", + "myRecords": "My Records", + "issue": "Issue", + "verify": "Verify" + }, + "landing": { + "subtitle": "Blockchain-based vaccination records on Stellar — soulbound, verifiable, tamper-proof.", + "connected": "✅ Connected: {{address}}", + "disconnect": "Disconnect", + "connect": "Connect Freighter Wallet", + "requiresFreighter": "Requires Freighter browser extension on Stellar Testnet." + }, + "patient": { + "connectPrompt": "Connect your wallet to view records.", + "connectWallet": "Connect Wallet", + "title": "My Vaccination Records", + "recordCount_one": "{{count}} record", + "recordCount_other": "{{count}} records", + "wallet": "Wallet: {{address}}", + "loading": "Loading…", + "error": "Error: {{message}}", + "noRecords": "No vaccination records found.", + "prevPage": "‹ Prev", + "nextPage": "Next ›", + "pageOf": "Page {{page}} of {{total}}" + }, + "issuer": { + "connectPrompt": "Connect your issuer wallet.", + "connectWallet": "Connect Wallet", + "accessDenied": "Access denied: issuer role required.", + "title": "Issue Vaccination NFT", + "patientAddress": "Patient Stellar Address", + "vaccineName": "Vaccine Name", + "vaccineNamePlaceholder": "e.g. COVID-19", + "dateAdministered": "Date Administered", + "submit": "Issue Vaccination NFT", + "submitting": "Minting…", + "error": "Error: {{message}}", + "success": "✅ Vaccination NFT minted! Token ID: {{tokenId}}", + "validation": { + "invalidAddress": "Must be a valid Stellar public key (G…, 56 chars)", + "vaccineRequired": "Vaccine name is required", + "dateRequired": "Date is required", + "dateFuture": "Date cannot be in the future" + } + }, + "verify": { + "title": "Verify Vaccination Status", + "placeholder": "Enter Stellar wallet address (G...)", + "submit": "Verify", + "checking": "Checking…", + "error": "Error: {{message}}" + }, + "nftCard": { + "date": "Date: {{date}}", + "issuer": "Issuer: {{address}}" + }, + "badge": { + "verified": "Verified: {{count}} Record", + "verified_other": "Verified: {{count}} Records", + "notFound": "No Records Found", + "revoked": "Certificate Revoked", + "verifying": "Verifying Status..." + }, + "modal": { + "title": "💉 Vaccination Record", + "ariaLabel": "Vaccination record details", + "close": "Close modal", + "vaccineName": "Vaccine Name", + "dateAdministered": "Date Administered", + "tokenId": "Token ID", + "issuerAddress": "Issuer Address", + "txHash": "Transaction Hash", + "viewExplorer": "View on Stellar Explorer ↗", + "noTxHash": "Transaction hash not available for this record." + } +} diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json new file mode 100644 index 0000000..36cfc40 --- /dev/null +++ b/frontend/src/locales/fr.json @@ -0,0 +1,79 @@ +{ + "nav": { + "home": "Accueil", + "myRecords": "Mes dossiers", + "issue": "Émettre", + "verify": "Vérifier" + }, + "landing": { + "subtitle": "Dossiers de vaccination sur Stellar — non-transférables, vérifiables, infalsifiables.", + "connected": "✅ Connecté : {{address}}", + "disconnect": "Déconnecter", + "connect": "Connecter le portefeuille Freighter", + "requiresFreighter": "Nécessite l'extension Freighter sur le réseau de test Stellar." + }, + "patient": { + "connectPrompt": "Connectez votre portefeuille pour voir vos dossiers.", + "connectWallet": "Connecter le portefeuille", + "title": "Mes dossiers de vaccination", + "recordCount_one": "{{count}} dossier", + "recordCount_other": "{{count}} dossiers", + "wallet": "Portefeuille : {{address}}", + "loading": "Chargement…", + "error": "Erreur : {{message}}", + "noRecords": "Aucun dossier de vaccination trouvé.", + "prevPage": "‹ Préc.", + "nextPage": "Suiv. ›", + "pageOf": "Page {{page}} sur {{total}}" + }, + "issuer": { + "connectPrompt": "Connectez votre portefeuille émetteur.", + "connectWallet": "Connecter le portefeuille", + "accessDenied": "Accès refusé : rôle émetteur requis.", + "title": "Émettre un NFT de vaccination", + "patientAddress": "Adresse Stellar du patient", + "vaccineName": "Nom du vaccin", + "vaccineNamePlaceholder": "ex. COVID-19", + "dateAdministered": "Date d'administration", + "submit": "Émettre le NFT de vaccination", + "submitting": "Émission en cours…", + "error": "Erreur : {{message}}", + "success": "✅ NFT de vaccination émis ! ID du jeton : {{tokenId}}", + "validation": { + "invalidAddress": "Doit être une clé publique Stellar valide (G…, 56 caractères)", + "vaccineRequired": "Le nom du vaccin est requis", + "dateRequired": "La date est requise", + "dateFuture": "La date ne peut pas être dans le futur" + } + }, + "verify": { + "title": "Vérifier le statut vaccinal", + "placeholder": "Entrez l'adresse du portefeuille Stellar (G...)", + "submit": "Vérifier", + "checking": "Vérification…", + "error": "Erreur : {{message}}" + }, + "nftCard": { + "date": "Date : {{date}}", + "issuer": "Émetteur : {{address}}" + }, + "badge": { + "verified": "Vérifié : {{count}} dossier", + "verified_other": "Vérifié : {{count}} dossiers", + "notFound": "Aucun dossier trouvé", + "revoked": "Certificat révoqué", + "verifying": "Vérification en cours..." + }, + "modal": { + "title": "💉 Dossier de vaccination", + "ariaLabel": "Détails du dossier de vaccination", + "close": "Fermer la fenêtre", + "vaccineName": "Nom du vaccin", + "dateAdministered": "Date d'administration", + "tokenId": "ID du jeton", + "issuerAddress": "Adresse de l'émetteur", + "txHash": "Hash de transaction", + "viewExplorer": "Voir sur Stellar Explorer ↗", + "noTxHash": "Hash de transaction non disponible pour ce dossier." + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index c0b2a9c..ec79161 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import './i18n'; import App from './App'; import ErrorBoundary from './components/ErrorBoundary'; import './index.css'; diff --git a/frontend/src/pages/IssuerDashboard.jsx b/frontend/src/pages/IssuerDashboard.jsx index bce7960..9d34a99 100644 --- a/frontend/src/pages/IssuerDashboard.jsx +++ b/frontend/src/pages/IssuerDashboard.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useFreighter'; import { useVaccination } from '../hooks/useVaccination'; import ConfirmMintDialog from '../components/ConfirmMintDialog'; @@ -16,24 +17,11 @@ const styles = { const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; const today = () => new Date().toISOString().split('T')[0]; - -function validate(form) { - const errors = {}; - if (!STELLAR_ADDRESS_RE.test(form.patient_address)) - errors.patient_address = 'Must be a valid Stellar public key (G…, 56 chars)'; - if (!form.vaccine_name.trim()) - errors.vaccine_name = 'Vaccine name is required'; - if (!form.date_administered) - errors.date_administered = 'Date is required'; - else if (form.date_administered > today()) - errors.date_administered = 'Date cannot be in the future'; - return errors; -} - const FORM_KEY = 'issuer_form_draft'; const EMPTY_FORM = { patient_address: '', vaccine_name: '', date_administered: '' }; export default function IssuerDashboard() { + const { t } = useTranslation(); const { publicKey, role, connect } = useAuth(); const { issueVaccination, loading } = useVaccination(); @@ -49,10 +37,22 @@ export default function IssuerDashboard() { const [mintResult, setMintResult] = useState(null); const [confirming, setConfirming] = useState(false); + const validate = (f) => { + const errors = {}; + if (!STELLAR_ADDRESS_RE.test(f.patient_address)) + errors.patient_address = t('issuer.validation.invalidAddress'); + if (!f.vaccine_name.trim()) + errors.vaccine_name = t('issuer.validation.vaccineRequired'); + if (!f.date_administered) + errors.date_administered = t('issuer.validation.dateRequired'); + else if (f.date_administered > today()) + errors.date_administered = t('issuer.validation.dateFuture'); + return errors; + }; + const errors = validate(form); const isValid = Object.keys(errors).length === 0; - // Persist form draft on every change useEffect(() => { sessionStorage.setItem(FORM_KEY, JSON.stringify(form)); }, [form]); @@ -67,7 +67,7 @@ export default function IssuerDashboard() { } if (role !== 'issuer') { - return

Access denied: issuer role required.

; + return

{t('issuer.accessDenied')}

; } const handleSubmit = async (e) => { @@ -81,9 +81,9 @@ export default function IssuerDashboard() { }; const fields = [ - { key: 'patient_address', label: 'Patient Stellar Address', placeholder: 'G...', type: 'text' }, - { key: 'vaccine_name', label: 'Vaccine Name', placeholder: 'e.g. COVID-19', type: 'text' }, - { key: 'date_administered', label: 'Date Administered', placeholder: '', type: 'date' }, + { key: 'patient_address', label: t('issuer.patientAddress'), placeholder: 'G...', type: 'text' }, + { key: 'vaccine_name', label: t('issuer.vaccineName'), placeholder: t('issuer.vaccineNamePlaceholder'), type: 'text' }, + { key: 'date_administered', label: t('issuer.dateAdministered'), placeholder: '', type: 'date' }, ]; return ( diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index 9570fd7..dc97871 100644 --- a/frontend/src/pages/Landing.jsx +++ b/frontend/src/pages/Landing.jsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useFreighter'; const styles = { @@ -9,18 +10,17 @@ const styles = { }; export default function Landing() { + const { t } = useTranslation(); const { publicKey, connect, disconnect } = useAuth(); return (

💉 VacciChain

-

- Blockchain-based vaccination records on Stellar — soulbound, verifiable, tamper-proof. -

+

{t('landing.subtitle')}

{publicKey ? ( <>

- ✅ Connected: {publicKey.slice(0, 8)}…{publicKey.slice(-4)} + {t('landing.connected', { address: `${publicKey.slice(0, 8)}…${publicKey.slice(-4)}` })}

)} -

Requires Freighter browser extension on Stellar Testnet.

+

{t('landing.requiresFreighter')}

); } diff --git a/frontend/src/pages/PatientDashboard.jsx b/frontend/src/pages/PatientDashboard.jsx index 13b8cd5..f3b9864 100644 --- a/frontend/src/pages/PatientDashboard.jsx +++ b/frontend/src/pages/PatientDashboard.jsx @@ -19,6 +19,7 @@ const styles = { }; export default function PatientDashboard() { + const { t } = useTranslation(); const { publicKey, connect } = useAuth(); const { fetchRecords, loading } = useVaccination(); const [records, setRecords] = useState([]); @@ -46,9 +47,11 @@ export default function PatientDashboard() { return (
-

My Vaccination Records

+

{t('patient.title')}

{total > 0 && ( - {total} record{total !== 1 ? 's' : ''} + + {t('patient.recordCount', { count: total })} + )}

@@ -80,10 +83,10 @@ export default function PatientDashboard() { disabled={page === 1} aria-label="Previous page" > - ‹ Prev + {t('patient.prevPage')} - Page {page} of {totalPages} + {t('patient.pageOf', { page, total: totalPages })} )} diff --git a/frontend/src/pages/VerifyPage.jsx b/frontend/src/pages/VerifyPage.jsx index a159dcf..ad490e3 100644 --- a/frontend/src/pages/VerifyPage.jsx +++ b/frontend/src/pages/VerifyPage.jsx @@ -58,7 +58,7 @@ export default function VerifyPage() { setWallet(e.target.value)} aria-label="Stellar wallet address to verify" diff --git a/python-service/main.py b/python-service/main.py index 5468791..62c3116 100644 --- a/python-service/main.py +++ b/python-service/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI, Request from routes.analytics import router as analytics_router from routes.batch import router as batch_router +from schemas import HealthResponse structlog.configure( processors=[ @@ -32,4 +33,4 @@ async def log_requests(request: Request, call_next): @app.get("/health") def health(): - return {"status": "ok"} + return HealthResponse(status="ok") diff --git a/python-service/routes/analytics.py b/python-service/routes/analytics.py index 43a11ca..6fc153b 100644 --- a/python-service/routes/analytics.py +++ b/python-service/routes/analytics.py @@ -3,12 +3,13 @@ from fastapi import APIRouter, HTTPException import httpx -router = APIRouter() +router = APIRouter(tags=["Analytics"]) BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:4000") # Anomaly threshold: flag issuers with more than this many mints in the dataset ANOMALY_THRESHOLD = int(os.getenv("ANOMALY_THRESHOLD", "50")) +_bearer = HTTPBearer(description="JWT issued by the VacciChain backend via POST /auth/verify") async def _fetch_events(event_type: str, limit: int = 500) -> list: async with httpx.AsyncClient() as client: diff --git a/python-service/routes/batch.py b/python-service/routes/batch.py index 85d0b9e..fc8e882 100644 --- a/python-service/routes/batch.py +++ b/python-service/routes/batch.py @@ -1,37 +1,45 @@ -import httpx import os -from fastapi import APIRouter -from pydantic import BaseModel -from typing import List +import httpx +from fastapi import APIRouter, HTTPException +from schemas import BatchVerifyRequest, BatchVerifyResponse, WalletResult -router = APIRouter() +router = APIRouter(tags=["Batch"]) BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:4000") -class BatchVerifyRequest(BaseModel): - wallets: List[str] - - -@router.post("/verify") +@router.post( + "/verify", + response_model=BatchVerifyResponse, + summary="Bulk verify Stellar wallet vaccination status", + description=( + "Accepts up to **100** Stellar public-key addresses and returns the vaccination " + "status for each one by querying the on-chain verification endpoint.\n\n" + "- Each address must be a valid Stellar public key starting with `G`.\n" + "- Wallets that cannot be reached are returned with an `error` field instead of " + "`vaccinated`/`record_count`.\n\n" + "**Auth:** No authentication required — mirrors the public `/verify/:wallet` endpoint." + ), + responses={ + 400: {"description": "Request contains more than 100 wallets"}, + }, +) async def batch_verify(request: BatchVerifyRequest): - """Bulk verify a list of Stellar wallet addresses.""" if len(request.wallets) > 100: - from fastapi import HTTPException raise HTTPException(status_code=400, detail="Maximum 100 wallets per request") - results = [] + results: list[WalletResult] = [] async with httpx.AsyncClient() as client: for wallet in request.wallets: try: res = await client.get(f"{BACKEND_URL}/verify/{wallet}", timeout=10) data = res.json() - results.append({ - "wallet": wallet, - "vaccinated": data.get("vaccinated", False), - "record_count": data.get("record_count", 0), - }) + results.append(WalletResult( + wallet=wallet, + vaccinated=data.get("vaccinated", False), + record_count=data.get("record_count", 0), + )) except Exception as e: - results.append({"wallet": wallet, "error": str(e)}) + results.append(WalletResult(wallet=wallet, error=str(e))) - return {"results": results, "total": len(results)} + return BatchVerifyResponse(results=results, total=len(results)) diff --git a/python-service/schemas.py b/python-service/schemas.py new file mode 100644 index 0000000..78d0c6b --- /dev/null +++ b/python-service/schemas.py @@ -0,0 +1,62 @@ +from pydantic import BaseModel, Field +from typing import List, Optional + + +# ── Health ──────────────────────────────────────────────────────────────────── + +class HealthResponse(BaseModel): + status: str = Field(..., description="Service liveness status", examples=["ok"]) + + +# ── Analytics ───────────────────────────────────────────────────────────────── + +class VaccinationRatesResponse(BaseModel): + note: str = Field(..., description="Data-source note for this placeholder response") + sample: dict = Field( + ..., + description="Map of vaccine name → total administrations", + examples=[{"COVID-19": 1240, "Influenza": 870}], + ) + + +class IssuerStat(BaseModel): + issuer: str = Field(..., description="Stellar public key of the issuer", examples=["GABC...XYZ"]) + total_issued: int = Field(..., description="Total vaccination NFTs minted by this issuer", ge=0) + last_active: str = Field(..., description="ISO-8601 date of most recent mint", examples=["2024-03-15"]) + + +class IssuerActivityResponse(BaseModel): + note: str = Field(..., description="Data-source note for this placeholder response") + sample: List[IssuerStat] + + +class AnomalyResponse(BaseModel): + note: str = Field(..., description="Detection methodology description") + flagged_issuers: List[str] = Field( + ..., + description="Stellar addresses of issuers flagged for unusual mint volume (>50 mints/hour)", + ) + + +# ── Batch ───────────────────────────────────────────────────────────────────── + +class BatchVerifyRequest(BaseModel): + wallets: List[str] = Field( + ..., + description="List of Stellar public-key addresses to verify (max 100)", + min_length=1, + max_length=100, + examples=[["GABC...XYZ", "GDEF...UVW"]], + ) + + +class WalletResult(BaseModel): + wallet: str = Field(..., description="Stellar public-key address that was checked") + vaccinated: Optional[bool] = Field(None, description="True if at least one valid vaccination record exists") + record_count: Optional[int] = Field(None, description="Number of vaccination records found", ge=0) + error: Optional[str] = Field(None, description="Error message if this wallet could not be verified") + + +class BatchVerifyResponse(BaseModel): + results: List[WalletResult] = Field(..., description="Per-wallet verification results") + total: int = Field(..., description="Number of wallets processed", ge=0)