diff --git a/frontend/src/components/ConfirmMintDialog.jsx b/frontend/src/components/ConfirmMintDialog.jsx index c360c5f..e6f2c98 100644 --- a/frontend/src/components/ConfirmMintDialog.jsx +++ b/frontend/src/components/ConfirmMintDialog.jsx @@ -10,8 +10,8 @@ const box = { }; const row = { display: 'flex', justifyContent: 'space-between', margin: '0.4rem 0', fontSize: '0.9rem' }; const btnRow = { display: 'flex', gap: '0.75rem', marginTop: '1.5rem' }; -const btnConfirm = { flex: 1, padding: '0.7rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, fontSize: '1rem', cursor: 'pointer' }; -const btnCancel = { flex: 1, padding: '0.7rem', background: 'transparent', color: '#94a3b8', border: '1px solid #334155', borderRadius: 8, fontSize: '1rem', cursor: 'pointer' }; +const btnConfirm = { flex: 1, padding: '0.7rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, fontSize: '1rem', cursor: 'pointer', minHeight: '44px' }; +const btnCancel = { flex: 1, padding: '0.7rem', background: 'transparent', color: '#94a3b8', border: '1px solid #334155', borderRadius: 8, fontSize: '1rem', cursor: 'pointer', minHeight: '44px' }; function getFocusableElements(root) { if (!root) return []; diff --git a/frontend/src/components/ConfirmationModal.jsx b/frontend/src/components/ConfirmationModal.jsx new file mode 100644 index 0000000..8b81d56 --- /dev/null +++ b/frontend/src/components/ConfirmationModal.jsx @@ -0,0 +1,129 @@ +import { useEffect, useRef } from 'react'; + +/** + * Confirmation modal for destructive actions. + * Keyboard accessible with focus trap and Escape to close. + */ +export default function ConfirmationModal({ isOpen, title, message, onConfirm, onCancel, loading = false, isDangerous = true }) { + const modalRef = useRef(null); + const confirmBtnRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') onCancel(); + if (e.key === 'Tab') { + const focusableElements = modalRef.current?.querySelectorAll('button'); + if (!focusableElements?.length) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + confirmBtnRef.current?.focus(); + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + if (!isOpen) return null; + + return ( + <> + ); } diff --git a/frontend/src/pages/AnalyticsDashboard.jsx b/frontend/src/pages/AnalyticsDashboard.jsx index 844a916..7b23f14 100644 --- a/frontend/src/pages/AnalyticsDashboard.jsx +++ b/frontend/src/pages/AnalyticsDashboard.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useAuth } from '../hooks/useFreighter'; +import Tooltip from '../components/Tooltip'; const ANALYTICS_BASE = import.meta.env.VITE_ANALYTICS_URL || 'http://localhost:8001'; const REFRESH_INTERVAL = 60_000; @@ -14,11 +15,11 @@ const s = { error: { color: '#f87171', padding: '0.75rem', background: '#1e293b', borderRadius: 8, marginBottom: '1rem' }, loading: { color: '#64748b', textAlign: 'center', padding: '2rem 0' }, table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }, - th: { textAlign: 'left', color: '#64748b', fontWeight: 500, padding: '0.5rem 0.75rem', borderBottom: '1px solid #1e293b' }, - td: { padding: '0.6rem 0.75rem', color: '#e2e8f0', borderBottom: '1px solid #1e293b' }, + th: { textAlign: 'left', color: '#64748b', fontWeight: 500, padding: '0.75rem', borderBottom: '1px solid #1e293b' }, + td: { padding: '0.75rem', color: '#e2e8f0', borderBottom: '1px solid #1e293b' }, badge: (severity) => ({ display: 'inline-block', - padding: '0.2rem 0.6rem', + padding: '0.25rem 0.75rem', borderRadius: 4, fontSize: '0.75rem', fontWeight: 600, @@ -41,9 +42,11 @@ function BarChart({ data }) {
{data.map((d) => (
- - {d.vaccine_name.length > 18 ? d.vaccine_name.slice(0, 17) + '…' : d.vaccine_name} - + + + {d.vaccine_name.length > 18 ? d.vaccine_name.slice(0, 17) + '…' : d.vaccine_name} + +
diff --git a/frontend/src/pages/IssuerDashboard.jsx b/frontend/src/pages/IssuerDashboard.jsx index ab52e2f..41f4a79 100644 --- a/frontend/src/pages/IssuerDashboard.jsx +++ b/frontend/src/pages/IssuerDashboard.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useFreighter'; import { useVaccination } from '../hooks/useVaccination'; +import { useToast } from '../hooks/useToast'; import ConfirmMintDialog from '../components/ConfirmMintDialog'; import CopyButton from '../components/CopyButton'; import RoleBadge from '../components/RoleBadge'; @@ -9,10 +10,10 @@ import RoleBadge from '../components/RoleBadge'; const styles = { page: { maxWidth: 500, width: '100%', margin: '2rem auto', padding: '0 1rem', boxSizing: 'border-box' }, form: { display: 'flex', flexDirection: 'column', gap: '1rem' }, - input: { padding: '0.6rem 0.75rem', background: '#1e293b', border: '1px solid #334155', borderRadius: 8, color: '#e2e8f0', fontSize: '1rem', width: '100%', boxSizing: 'border-box' }, - inputError: { padding: '0.6rem 0.75rem', background: '#1e293b', border: '1px solid #f87171', borderRadius: 8, color: '#e2e8f0', fontSize: '1rem', width: '100%', boxSizing: 'border-box' }, - btn: { padding: '0.7rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, fontSize: '1rem', width: '100%', touchAction: 'manipulation' }, - btnDisabled: { padding: '0.7rem', background: '#334155', color: '#64748b', border: 'none', borderRadius: 8, fontSize: '1rem', cursor: 'not-allowed', width: '100%' }, + input: { padding: '0.6rem 0.75rem', background: '#1e293b', border: '1px solid #334155', borderRadius: 8, color: '#e2e8f0', fontSize: '1rem', width: '100%', boxSizing: 'border-box', minHeight: '44px' }, + inputError: { padding: '0.6rem 0.75rem', background: '#1e293b', border: '1px solid #f87171', borderRadius: 8, color: '#e2e8f0', fontSize: '1rem', width: '100%', boxSizing: 'border-box', minHeight: '44px' }, + btn: { padding: '0.7rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, fontSize: '1rem', width: '100%', touchAction: 'manipulation', minHeight: '44px', cursor: 'pointer' }, + btnDisabled: { padding: '0.7rem', background: '#334155', color: '#64748b', border: 'none', borderRadius: 8, fontSize: '1rem', cursor: 'not-allowed', width: '100%', minHeight: '44px' }, label: { color: '#94a3b8', fontSize: '0.85rem', marginBottom: '0.25rem' }, fieldError: { color: '#f87171', fontSize: '0.78rem', marginTop: '0.25rem' }, statusBadge: { display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', borderRadius: 6, fontSize: '0.85rem', marginBottom: '1rem' }, @@ -30,6 +31,7 @@ export default function IssuerDashboard() { const { t } = useTranslation(); const { publicKey, role, connect } = useAuth(); const { issueVaccination, checkIssuerStatus, loading } = useVaccination(); + const toast = useToast(); const [form, setForm] = useState(() => { try { @@ -100,6 +102,9 @@ export default function IssuerDashboard() { setMintResult(result); setForm(EMPTY_FORM); sessionStorage.removeItem(FORM_KEY); + toast('Vaccination NFT issued successfully!', 'success'); + } else { + toast('Failed to issue vaccination NFT. Please try again.', 'error'); } }; diff --git a/frontend/src/pages/IssuerOnboarding.jsx b/frontend/src/pages/IssuerOnboarding.jsx index a7721ed..26aef9b 100644 --- a/frontend/src/pages/IssuerOnboarding.jsx +++ b/frontend/src/pages/IssuerOnboarding.jsx @@ -1,18 +1,20 @@ import { useState } from 'react'; import { useAuth } from '../hooks/useFreighter'; +import { useToast } from '../hooks/useToast'; const s = { page: { maxWidth: 520, width: '100%', margin: '2rem auto', padding: '0 1rem', boxSizing: 'border-box' }, field: { display: 'flex', flexDirection: 'column', gap: '0.35rem', marginBottom: '1rem' }, label: { color: '#94a3b8', fontSize: '0.85rem' }, - input: { padding: '0.5rem 0.75rem', background: '#1e293b', border: '1px solid #334155', borderRadius: 6, color: '#e2e8f0', fontSize: '0.9rem' }, - btn: { padding: '0.55rem 1.25rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: '0.9rem', marginTop: '0.5rem' }, + input: { padding: '0.6rem 0.75rem', background: '#1e293b', border: '1px solid #334155', borderRadius: 6, color: '#e2e8f0', fontSize: '0.9rem', minHeight: '44px' }, + btn: { padding: '0.7rem 1.25rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: '0.9rem', marginTop: '0.5rem', minHeight: '44px' }, success:{ marginTop: '1rem', padding: '0.75rem 1rem', background: '#14532d', borderRadius: 8, color: '#86efac', fontSize: '0.9rem' }, error: { marginTop: '0.5rem', color: '#f87171', fontSize: '0.9rem' }, }; export default function IssuerOnboarding() { const { publicKey, connect, apiFetch } = useAuth(); + const toast = useToast(); const [form, setForm] = useState({ name: '', license_number: '', country: '' }); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(null); @@ -47,8 +49,10 @@ export default function IssuerOnboarding() { if (!res.ok) throw new Error(data.error || 'Submission failed'); setSuccess(data.message); setForm({ name: '', license_number: '', country: '' }); + toast('Application submitted successfully!', 'success'); } catch (err) { setError(err.message); + toast(`Error: ${err.message}`, 'error'); } finally { setLoading(false); } diff --git a/frontend/src/pages/PatientDashboard.jsx b/frontend/src/pages/PatientDashboard.jsx index 0c774e9..060ec5c 100644 --- a/frontend/src/pages/PatientDashboard.jsx +++ b/frontend/src/pages/PatientDashboard.jsx @@ -15,7 +15,7 @@ import ConsentScreen from '../components/ConsentScreen'; const styles = { page: { maxWidth: 700, width: '100%', margin: '2rem auto', padding: '0 1rem', boxSizing: 'border-box' }, header: { borderLeft: '4px solid #0ea5e9', paddingLeft: '0.75rem', marginBottom: '1.5rem' }, - btn: { padding: '0.6rem 1.5rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }, + btn: { padding: '0.7rem 1.5rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', minHeight: '44px', minWidth: '44px' }, }; export default function PatientDashboard() { diff --git a/frontend/src/pages/VerifyPage.jsx b/frontend/src/pages/VerifyPage.jsx index 5c2b771..bf088c1 100644 --- a/frontend/src/pages/VerifyPage.jsx +++ b/frontend/src/pages/VerifyPage.jsx @@ -7,8 +7,8 @@ import { useToast } from '../hooks/useToast'; const styles = { page: { maxWidth: 600, margin: '2rem auto', padding: '0 1rem' }, - input: { padding: '0.6rem 0.75rem', background: 'var(--input-bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: '1rem', width: '100%' }, - btn: { padding: '0.6rem 1.5rem', background: 'var(--btn-primary)', color: '#fff', border: 'none', borderRadius: 8, fontSize: '1rem', marginTop: '0.75rem' }, + input: { padding: '0.6rem 0.75rem', background: 'var(--input-bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: '1rem', width: '100%', boxSizing: 'border-box', minHeight: '44px' }, + btn: { padding: '0.7rem 1.5rem', background: 'var(--btn-primary)', color: '#fff', border: 'none', borderRadius: 8, fontSize: '1rem', marginTop: '0.75rem', minHeight: '44px', cursor: 'pointer' }, }; export default function VerifyPage() { @@ -28,6 +28,7 @@ export default function VerifyPage() { const data = await res.json(); if (!res.ok) throw new Error(data.error); setResult(data); + toast('Verification successful', 'success'); } catch (e) { setError(e.message || 'Verification failed.'); toast(e.message || 'Verification failed.', 'error');