Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/LanguageSelector.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: 'flex', gap: '0.25rem', marginLeft: 'auto' }}>
{LANGUAGES.map(({ code, label }) => (
<button
key={code}
onClick={() => handleChange(code)}
aria-pressed={i18n.language === code}
style={{
padding: '0.25rem 0.6rem',
background: i18n.language === code ? '#0ea5e9' : 'transparent',
color: i18n.language === code ? '#fff' : '#94a3b8',
border: '1px solid',
borderColor: i18n.language === code ? '#0ea5e9' : '#334155',
borderRadius: 6,
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
{label}
</button>
))}
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/NFTCard.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import CopyButton from './CopyButton';

export default function NFTCard({ record, onClick }) {
const { t } = useTranslation();

return (
<div
role="button"
Expand Down
70 changes: 25 additions & 45 deletions frontend/src/components/RecordDetailModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,39 @@ import CopyButton from './CopyButton';
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',
fontSize: '1.25rem', cursor: 'pointer', lineHeight: 1,
};

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 (
<div
Expand All @@ -52,15 +44,14 @@ export default function RecordDetailModal({ record, onClose }) {
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-label="Vaccination record details"
aria-label={t('modal.ariaLabel')}
tabIndex={-1}
ref={(el) => el?.focus()}
>
<div style={modal}>
<button style={closeBtn} onClick={onClose} aria-label="Close modal">✕</button>

<button style={closeBtn} onClick={onClose} aria-label={t('modal.close')}>✕</button>
<h2 style={{ marginBottom: '1.5rem', color: '#38bdf8', fontSize: '1.2rem' }}>
💉 Vaccination Record
{t('modal.title')}
</h2>

<div style={row}>
Expand Down Expand Up @@ -97,30 +88,19 @@ export default function RecordDetailModal({ record, onClose }) {
<CopyButton text={record.tx_hash} label="transaction hash" />
</p>
</div>
)}

))}
<div style={{ marginTop: '1.5rem' }}>
{explorerUrl ? (
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: '0.6rem 1.25rem',
background: '#0ea5e9',
color: '#fff',
borderRadius: 8,
textDecoration: 'none',
fontSize: '0.9rem',
}}
style={{ display: 'inline-block', padding: '0.6rem 1.25rem', background: '#0ea5e9', color: '#fff', borderRadius: 8, textDecoration: 'none', fontSize: '0.9rem' }}
>
View on Stellar Explorer ↗
{t('modal.viewExplorer')}
</a>
) : (
<p style={{ color: '#475569', fontSize: '0.85rem' }}>
Transaction hash not available for this record.
</p>
<p style={{ color: '#475569', fontSize: '0.85rem' }}>{t('modal.noTxHash')}</p>
)}
</div>
</div>
Expand Down
87 changes: 28 additions & 59 deletions frontend/src/components/VerificationBadge.jsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,42 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

const LoadingSpinner = () => (
<svg
className="animate-spin"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
<svg
width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="3"
strokeLinecap="round" strokeLinejoin="round"
style={{ animation: 'spin 1s linear infinite' }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</svg>
);

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: <LoadingSpinner />
}
bg: 'rgba(37, 99, 235, 0.1)', border: 'rgba(37, 99, 235, 0.2)', color: '#2563eb',
label: t('badge.verifying'),
icon: <LoadingSpinner />,
},
};

let effectiveStatus = status;
Expand All @@ -62,33 +46,18 @@ export default function VerificationBadge({ status, vaccinated, recordCount = 0
const config = configs[effectiveStatus] || configs['not-found'];

return (
<div
<div
id="verification-badge"
aria-label={config.label}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.5rem 1rem',
borderRadius: '12px',
backgroundColor: config.bg,
border: `1px solid ${config.border}`,
color: config.color,
fontSize: '0.875rem',
fontWeight: '600',
transition: 'all 0.2s ease',
cursor: 'default',
backdropFilter: 'blur(4px)',
display: 'inline-flex', alignItems: 'center', gap: '0.625rem',
padding: '0.5rem 1rem', borderRadius: '12px',
backgroundColor: config.bg, border: `1px solid ${config.border}`,
color: config.color, fontSize: '0.875rem', fontWeight: '600',
transition: 'all 0.2s ease', cursor: 'default', backdropFilter: 'blur(4px)',
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '18px',
height: '18px',
fontSize: '1rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '18px', height: '18px', fontSize: '1rem' }}>
{config.icon}
</span>
<span>{config.label}</span>
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/i18n.js
Original file line number Diff line number Diff line change
@@ -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;
79 changes: 79 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
Loading
Loading