diff --git a/frontend/src/components/FeedbackButton.jsx b/frontend/src/components/FeedbackButton.jsx new file mode 100644 index 0000000..d71e965 --- /dev/null +++ b/frontend/src/components/FeedbackButton.jsx @@ -0,0 +1,66 @@ +import { useButtonState } from '../hooks/useButtonState'; + +const STATE_LABELS = { + loading: { icon: '⏳', suffix: '…' }, + success: { icon: '✅', suffix: '' }, + error: { icon: '❌', suffix: '' }, +}; + +const STATE_STYLES = { + idle: {}, + loading: { opacity: 0.8, cursor: 'not-allowed' }, + success: { background: '#16a34a' }, + error: { background: '#dc2626' }, +}; + +/** + * Button with idle → loading → success/error → idle feedback states. + * + * Props: + * onClick - async function to run (required) + * style - base inline style object + * children - idle label + * loadingLabel / successLabel / errorLabel - override state labels + * resetDelay - ms before returning to idle (default 2000) + * ...rest - forwarded to + ); +} diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index 462f561..829ef6f 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -4,6 +4,7 @@ import DarkModeToggle from './DarkModeToggle'; import Tooltip from './Tooltip'; import WalletConnectionProgress from './WalletConnectionProgress'; import { useAuth } from '../hooks/useFreighter'; +import FeedbackButton from './FeedbackButton'; const NAV_LINKS = [ { to: '/', label: 'Home' }, @@ -36,10 +37,11 @@ function WalletIndicator() { if (!publicKey) { return (
- + Connect Wallet + {loading && } {!loading && error && }
diff --git a/frontend/src/hooks/useButtonState.js b/frontend/src/hooks/useButtonState.js new file mode 100644 index 0000000..ac7ae7b --- /dev/null +++ b/frontend/src/hooks/useButtonState.js @@ -0,0 +1,35 @@ +import { useState, useCallback, useRef } from 'react'; + +/** + * Manages button feedback states: idle → loading → success | error → idle + * @param {number} resetDelay - ms before returning to idle after success/error (default 2000) + * @returns {{ state, run, buttonProps }} + */ +export function useButtonState(resetDelay = 2000) { + const [state, setState] = useState('idle'); // 'idle' | 'loading' | 'success' | 'error' + const timerRef = useRef(null); + + const run = useCallback(async (asyncFn) => { + if (state === 'loading') return; + clearTimeout(timerRef.current); + setState('loading'); + try { + const result = await asyncFn(); + setState('success'); + timerRef.current = setTimeout(() => setState('idle'), resetDelay); + return result; + } catch (err) { + setState('error'); + timerRef.current = setTimeout(() => setState('idle'), resetDelay); + throw err; + } + }, [state, resetDelay]); + + const buttonProps = { + disabled: state === 'loading', + 'aria-busy': state === 'loading', + 'aria-live': 'polite', + }; + + return { state, run, buttonProps }; +} diff --git a/frontend/src/pages/IssuerDashboard.jsx b/frontend/src/pages/IssuerDashboard.jsx index fe7596e..77897d9 100644 --- a/frontend/src/pages/IssuerDashboard.jsx +++ b/frontend/src/pages/IssuerDashboard.jsx @@ -6,6 +6,7 @@ import { useToast } from '../hooks/useToast'; import ConfirmMintDialog from '../components/ConfirmMintDialog'; import CopyButton from '../components/CopyButton'; import RoleBadge from '../components/RoleBadge'; +import FeedbackButton from '../components/FeedbackButton'; const styles = { page: { maxWidth: 500, width: '100%', margin: '2rem auto', padding: '0 1rem', boxSizing: 'border-box' }, @@ -95,8 +96,7 @@ export default function IssuerDashboard() { ); } - const handleSubmit = async (e) => { - e.preventDefault(); + const handleSubmit = async () => { const result = await issueVaccination(form); if (result) { setMintResult(result); @@ -105,6 +105,7 @@ export default function IssuerDashboard() { toast('Vaccination NFT issued successfully!', 'success'); } else { toast('Failed to issue vaccination NFT. Please try again.', 'error'); + throw new Error('Issue failed'); } }; @@ -116,7 +117,7 @@ export default function IssuerDashboard() { -
+ e.preventDefault()} role="form"> {[ { key: 'patient_address', label: 'Patient Stellar Address', placeholder: 'G...' }, { key: 'vaccine_name', label: 'Vaccine Name', placeholder: 'e.g. COVID-19' }, @@ -135,9 +136,17 @@ export default function IssuerDashboard() { {errors[key] &&

{errors[key]}

} ))} - + + Issue Vaccination NFT +
{mintResult && ( diff --git a/frontend/src/pages/VerifyPage.jsx b/frontend/src/pages/VerifyPage.jsx index 2d93aab..fec0d90 100644 --- a/frontend/src/pages/VerifyPage.jsx +++ b/frontend/src/pages/VerifyPage.jsx @@ -4,6 +4,7 @@ import VerificationBadge from '../components/VerificationBadge'; import NFTCard from '../components/NFTCard'; import CopyButton from '../components/CopyButton'; import { useToast } from '../hooks/useToast'; +import FeedbackButton from '../components/FeedbackButton'; const styles = { page: { maxWidth: 600, margin: '2rem auto', padding: '0 1rem' }, @@ -16,25 +17,21 @@ export default function VerifyPage() { const toast = useToast(); const [wallet, setWallet] = useState(''); const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const verify = async (address) => { - setLoading(true); setResult(null); setError(null); - try { - const res = await fetch(`/v1/verify/${address.trim()}`); - 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'); - } finally { - setLoading(false); + const res = await fetch(`/v1/verify/${address.trim()}`); + const data = await res.json(); + if (!res.ok) { + const msg = data.error || 'Verification failed.'; + setError(msg); + toast(msg, 'error'); + throw new Error(msg); } + setResult(data); + toast('Verification successful', 'success'); }; useEffect(() => { @@ -46,19 +43,10 @@ export default function VerifyPage() { } }, []); - const handleVerify = (e) => { - e.preventDefault(); - const trimmed = wallet.trim(); - const url = new URL(window.location); - url.searchParams.set('wallet', trimmed); - window.history.pushState({}, '', url); - verify(trimmed); - }; - return (

Verify Vaccination Status

-
+ e.preventDefault()}> @@ -71,9 +59,22 @@ export default function VerifyPage() { aria-label="Stellar wallet address to verify" required /> - + { + const trimmed = wallet.trim(); + const url = new URL(window.location); + url.searchParams.set('wallet', trimmed); + window.history.pushState({}, '', url); + return verify(trimmed); + }} + loadingLabel="⏳ Checking…" + successLabel="✅ Verified" + errorLabel="❌ Failed" + aria-label="Verify wallet vaccination status" + > + Verify +