From f7827de8a58057d0b9afa85a39daa1e53fabc5c8 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Mon, 27 Apr 2026 12:12:09 +0000 Subject: [PATCH 1/2] feat: add react-i18next with English and French support - Integrate i18next + react-i18next - Add EN/FR translation files (src/locales/) - Externalize all user-facing strings via t() calls - Add LanguageSelector component in nav header - Persist selected language to localStorage --- frontend/package.json | 4 +- frontend/src/App.jsx | 13 ++- frontend/src/components/LanguageSelector.jsx | 39 +++++++ frontend/src/components/NFTCard.jsx | 8 +- frontend/src/components/RecordDetailModal.jsx | 101 ++++++------------ frontend/src/components/VerificationBadge.jsx | 87 +++++---------- frontend/src/i18n.js | 13 +++ frontend/src/locales/en.json | 79 ++++++++++++++ frontend/src/locales/fr.json | 79 ++++++++++++++ frontend/src/main.jsx | 1 + frontend/src/pages/IssuerDashboard.jsx | 52 ++++----- frontend/src/pages/Landing.jsx | 14 +-- frontend/src/pages/PatientDashboard.jsx | 28 +++-- frontend/src/pages/VerifyPage.jsx | 10 +- 14 files changed, 344 insertions(+), 184 deletions(-) create mode 100644 frontend/src/components/LanguageSelector.jsx create mode 100644 frontend/src/i18n.js create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/fr.json 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/App.jsx b/frontend/src/App.jsx index c2ccae1..7f007a4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,19 +1,24 @@ import { Routes, Route, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import Landing from './pages/Landing'; import PatientDashboard from './pages/PatientDashboard'; import IssuerDashboard from './pages/IssuerDashboard'; import VerifyPage from './pages/VerifyPage'; import { AuthProvider } from './hooks/useFreighter'; +import LanguageSelector from './components/LanguageSelector'; export default function App() { + const { t } = useTranslation(); + return ( } /> 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 9d3fc64..d4ef4d3 100644 --- a/frontend/src/components/NFTCard.jsx +++ b/frontend/src/components/NFTCard.jsx @@ -1,4 +1,8 @@ +import { useTranslation } from 'react-i18next'; + export default function NFTCard({ record, onClick }) { + const { t } = useTranslation(); + return (
#{record.token_id}

- Date: {record.date_administered} + {t('nftCard.date', { date: record.date_administered })}

- Issuer: {record.issuer?.slice(0, 8)}…{record.issuer?.slice(-4)} + {t('nftCard.issuer', { address: `${record.issuer?.slice(0, 8)}…${record.issuer?.slice(-4)}` })}

); diff --git a/frontend/src/components/RecordDetailModal.jsx b/frontend/src/components/RecordDetailModal.jsx index 427f415..d406f30 100644 --- a/frontend/src/components/RecordDetailModal.jsx +++ b/frontend/src/components/RecordDetailModal.jsx @@ -1,27 +1,19 @@ +import { useTranslation } from 'react-i18next'; + const STELLAR_EXPERT_BASE = 'https://stellar.expert/explorer/testnet/tx'; const overlay = { - position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.7)', + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', - zIndex: 1000, - padding: '1rem', + zIndex: 1000, padding: '1rem', }; - const modal = { - background: '#0f172a', - border: '1px solid #334155', - borderRadius: 16, - padding: '2rem', - width: '100%', - maxWidth: 520, - color: '#e2e8f0', - position: 'relative', + background: '#0f172a', border: '1px solid #334155', borderRadius: 16, + padding: '2rem', width: '100%', maxWidth: 520, color: '#e2e8f0', position: 'relative', }; - const row = { marginBottom: '1rem' }; -const label = { fontSize: '0.75rem', color: '#64748b', marginBottom: '0.2rem' }; -const value = { fontSize: '0.95rem', color: '#e2e8f0', wordBreak: 'break-all' }; +const labelStyle = { fontSize: '0.75rem', color: '#64748b', marginBottom: '0.2rem' }; +const valueStyle = { fontSize: '0.95rem', color: '#e2e8f0', wordBreak: 'break-all' }; const closeBtn = { position: 'absolute', top: '1rem', right: '1rem', background: 'none', border: 'none', color: '#94a3b8', @@ -29,19 +21,21 @@ const closeBtn = { }; export default function RecordDetailModal({ record, onClose }) { - if (!record) return null; + const { t } = useTranslation(); - const handleOverlayClick = (e) => { - 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')}

- -
-

Vaccine Name

-

{record.vaccine_name}

-
- -
-

Date Administered

-

{record.date_administered}

-
- -
-

Token ID

-

#{record.token_id}

-
- -
-

Issuer Address

-

{record.issuer}

-
- - {record.tx_hash && ( -
-

Transaction Hash

-

{record.tx_hash}

+ {fields.map(({ label, value }) => ( +
+

{label}

+

{value}

- )} - + ))}
{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 6cede93..44786ad 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 './index.css'; diff --git a/frontend/src/pages/IssuerDashboard.jsx b/frontend/src/pages/IssuerDashboard.jsx index 124d221..e688ea2 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'; @@ -15,24 +16,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, error } = useVaccination(); @@ -47,10 +35,22 @@ export default function IssuerDashboard() { const [touched, setTouched] = useState({}); const [success, setSuccess] = useState(null); + 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]); @@ -58,14 +58,14 @@ export default function IssuerDashboard() { if (!publicKey) { return (
-

Connect your issuer wallet.

- +

{t('issuer.connectPrompt')}

+
); } if (role !== 'issuer') { - return

Access denied: issuer role required.

; + return

{t('issuer.accessDenied')}

; } const handleSubmit = async (e) => { @@ -73,21 +73,21 @@ export default function IssuerDashboard() { setSuccess(null); const result = await issueVaccination(form); if (result) { - setSuccess(`Vaccination NFT minted! Token ID: ${result.token_id}`); + setSuccess(t('issuer.success', { tokenId: result.token_id })); setForm(EMPTY_FORM); sessionStorage.removeItem(FORM_KEY); } }; 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 (
-

Issue Vaccination NFT

+

{t('issuer.title')}

{fields.map(({ key, label, placeholder, type }) => { const hasError = touched[key] && errors[key]; @@ -112,11 +112,11 @@ export default function IssuerDashboard() { type="submit" disabled={!isValid || loading} > - {loading ? 'Minting…' : 'Issue Vaccination NFT'} + {loading ? t('issuer.submitting') : t('issuer.submit')}
- {error &&

Error: {error}

} - {success &&

✅ {success}

} + {error &&

{t('issuer.error', { message: error })}

} + {success &&

{success}

}
); } diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index 05b46e5..1c3616c 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,29 +10,28 @@ 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 556bae4..1533d44 100644 --- a/frontend/src/pages/PatientDashboard.jsx +++ b/frontend/src/pages/PatientDashboard.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useFreighter'; import { useVaccination } from '../hooks/useVaccination'; import { usePagination } from '../hooks/usePagination'; @@ -17,6 +18,7 @@ const styles = { }; export default function PatientDashboard() { + const { t } = useTranslation(); const { publicKey, connect } = useAuth(); const { fetchRecords, loading, error } = useVaccination(); const [records, setRecords] = useState([]); @@ -34,8 +36,8 @@ export default function PatientDashboard() { if (!publicKey) { return (
-

Connect your wallet to view records.

- +

{t('patient.connectPrompt')}

+
); } @@ -43,16 +45,20 @@ export default function PatientDashboard() { return (
-

My Vaccination Records

+

{t('patient.title')}

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

Wallet: {publicKey}

+

+ {t('patient.wallet', { address: publicKey })} +

- {loading &&

Loading…

} - {error &&

Error: {error}

} - {!loading && total === 0 &&

No vaccination records found.

} + {loading &&

{t('patient.loading')}

} + {error &&

{t('patient.error', { message: error })}

} + {!loading && total === 0 &&

{t('patient.noRecords')}

} {currentItems.map((r) => )} @@ -64,10 +70,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 9043f0b..7d093d7 100644 --- a/frontend/src/pages/VerifyPage.jsx +++ b/frontend/src/pages/VerifyPage.jsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import VerificationBadge from '../components/VerificationBadge'; import NFTCard from '../components/NFTCard'; @@ -9,6 +10,7 @@ const styles = { }; export default function VerifyPage() { + const { t } = useTranslation(); const [wallet, setWallet] = useState(''); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); @@ -33,21 +35,21 @@ export default function VerifyPage() { return (
-

Verify Vaccination Status

+

{t('verify.title')}

setWallet(e.target.value)} required />
- {error &&

Error: {error}

} + {error &&

{t('verify.error', { message: error })}

} {result && (
From 966b5f57288afbf27740ca92dca968acb516b4a5 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Mon, 27 Apr 2026 12:22:56 +0000 Subject: [PATCH 2/2] feat: add Pydantic response models and OpenAPI docs to analytics service - Add schemas.py with typed models for all endpoints - Document auth requirements (Bearer JWT) on analytics routes - Document batch/verify input limits (max 100), error responses - /docs (Swagger UI) and /redoc available out of the box --- python-service/main.py | 20 ++++-- python-service/routes/analytics.py | 109 ++++++++++++++++------------- python-service/routes/batch.py | 48 +++++++------ python-service/schemas.py | 62 ++++++++++++++++ 4 files changed, 167 insertions(+), 72 deletions(-) create mode 100644 python-service/schemas.py diff --git a/python-service/main.py b/python-service/main.py index 06611ad..1162e8a 100644 --- a/python-service/main.py +++ b/python-service/main.py @@ -1,14 +1,26 @@ -import os from fastapi import FastAPI from routes.analytics import router as analytics_router from routes.batch import router as batch_router +from schemas import HealthResponse -app = FastAPI(title="VacciChain Analytics", version="1.0.0") +app = FastAPI( + title="VacciChain Analytics", + version="1.0.0", + description=( + "Analytics and batch-verification service for the VacciChain platform.\n\n" + "**Authentication:** Analytics endpoints (`/analytics/*`) require a valid JWT " + "issued by the VacciChain backend (`Authorization: Bearer `). " + "The `/batch/verify` endpoint is public. " + "The `/health` endpoint is public." + ), + docs_url="/docs", + redoc_url="/redoc", +) app.include_router(analytics_router, prefix="/analytics") app.include_router(batch_router, prefix="/batch") -@app.get("/health") +@app.get("/health", response_model=HealthResponse, tags=["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 7a66949..5e77f6e 100644 --- a/python-service/routes/analytics.py +++ b/python-service/routes/analytics.py @@ -1,57 +1,70 @@ import os -import httpx -from fastapi import APIRouter, HTTPException -from collections import defaultdict, Counter +from fastapi import APIRouter, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from schemas import VaccinationRatesResponse, IssuerActivityResponse, AnomalyResponse -router = APIRouter() +router = APIRouter(tags=["Analytics"]) BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:4000") +_bearer = HTTPBearer(description="JWT issued by the VacciChain backend via POST /auth/verify") -async def _fetch_records(wallet: str) -> dict: - async with httpx.AsyncClient() as client: - res = await client.get(f"{BACKEND_URL}/verify/{wallet}", timeout=10) - res.raise_for_status() - return res.json() - - -@router.get("/rates") -async def vaccination_rates(): - """ - Returns aggregated vaccination counts by vaccine type. - In production this would query an indexed event store. - This endpoint demonstrates the analytics shape. - """ - # Placeholder: real implementation queries Horizon event stream - return { - "note": "Connect to Horizon event stream for live data", - "sample": { - "COVID-19": 1240, - "Influenza": 870, - "Hepatitis B": 430, - }, - } - - -@router.get("/issuers") -async def issuer_activity(): - """Issuer activity stats — volume and last active timestamp.""" - return { - "note": "Derived from on-chain mint events", - "sample": [ + +@router.get( + "/rates", + response_model=VaccinationRatesResponse, + summary="Vaccination rates by vaccine type", + description=( + "Returns aggregated vaccination counts grouped by vaccine type. " + "In production this queries an indexed Horizon event stream.\n\n" + "**Auth:** Bearer JWT required." + ), +) +async def vaccination_rates( + _: HTTPAuthorizationCredentials = Security(_bearer), +): + return VaccinationRatesResponse( + note="Connect to Horizon event stream for live data", + sample={"COVID-19": 1240, "Influenza": 870, "Hepatitis B": 430}, + ) + + +@router.get( + "/issuers", + response_model=IssuerActivityResponse, + summary="Issuer activity — volume and last active date", + description=( + "Returns per-issuer mint volume and last-active date, " + "derived from on-chain mint events.\n\n" + "**Auth:** Bearer JWT required." + ), +) +async def issuer_activity( + _: HTTPAuthorizationCredentials = Security(_bearer), +): + return IssuerActivityResponse( + note="Derived from on-chain mint events", + sample=[ {"issuer": "GABC...XYZ", "total_issued": 312, "last_active": "2024-03-15"}, {"issuer": "GDEF...UVW", "total_issued": 98, "last_active": "2024-03-10"}, ], - } - - -@router.get("/anomalies") -async def anomaly_detection(): - """ - Flag issuers with unusually high mint volume in a short window. - Threshold: >50 mints in 1 hour is flagged. - """ - return { - "note": "Anomaly detection based on mint event frequency", - "flagged_issuers": [], - } + ) + + +@router.get( + "/anomalies", + response_model=AnomalyResponse, + summary="Flag issuers with unusual mint volume", + description=( + "Detects issuers exceeding 50 mints within any 1-hour window and returns " + "their Stellar addresses.\n\n" + "**Auth:** Bearer JWT required." + ), +) +async def anomaly_detection( + _: HTTPAuthorizationCredentials = Security(_bearer), +): + return AnomalyResponse( + note="Anomaly detection based on mint event frequency (threshold: >50 mints/hour)", + flagged_issuers=[], + ) 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)