Skip to content
Open
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
66 changes: 66 additions & 0 deletions frontend/src/components/FeedbackButton.jsx
Original file line number Diff line number Diff line change
@@ -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 <button>
*/
export default function FeedbackButton({
onClick,
style = {},
children,
loadingLabel,
successLabel,
errorLabel,
resetDelay = 2000,
...rest
}) {
const { state, run, buttonProps } = useButtonState(resetDelay);

const handleClick = () => run(onClick);

const label = (() => {
if (state === 'loading') return loadingLabel ?? `${STATE_LABELS.loading.icon} ${children}${STATE_LABELS.loading.suffix}`;
if (state === 'success') return successLabel ?? `${STATE_LABELS.success.icon} Success`;
if (state === 'error') return errorLabel ?? `${STATE_LABELS.error.icon} Error`;
return children;
})();

// Fixed min-width prevents layout shift between states
const baseMinWidth = style.minWidth ?? '10rem';

return (
<button
{...rest}
{...buttonProps}
onClick={handleClick}
style={{
...style,
...STATE_STYLES[state],
minWidth: baseMinWidth,
boxSizing: 'border-box',
}}
>
{label}
</button>
);
}
17 changes: 9 additions & 8 deletions frontend/src/components/NavBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -36,25 +37,25 @@ function WalletIndicator() {
if (!publicKey) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', alignItems: 'flex-end' }}>
<button
<FeedbackButton
onClick={connect}
disabled={loading}
aria-busy={loading}
loadingLabel="⏳ Connecting…"
successLabel="✅ Connected"
errorLabel="❌ Failed"
style={{
padding: '0.6rem 1rem',
background: 'var(--btn-primary)',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: '0.85rem',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
cursor: 'pointer',
minHeight: '44px',
minWidth: '44px',
minWidth: '10rem',
}}
>
{loading ? 'Connecting…' : 'Connect Wallet'}
</button>
Connect Wallet
</FeedbackButton>
{loading && <WalletConnectionProgress step={connectionStep} />}
{!loading && error && <WalletConnectionProgress error={error} />}
</div>
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/hooks/useButtonState.js
Original file line number Diff line number Diff line change
@@ -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 };
}
21 changes: 15 additions & 6 deletions frontend/src/pages/IssuerDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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);
Expand All @@ -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');
}
};

Expand All @@ -116,7 +117,7 @@ export default function IssuerDashboard() {
<RoleBadge role="issuer" />
</div>
</div>
<form style={styles.form} onSubmit={handleSubmit} role="form">
<form style={styles.form} onSubmit={(e) => e.preventDefault()} role="form">
{[
{ key: 'patient_address', label: 'Patient Stellar Address', placeholder: 'G...' },
{ key: 'vaccine_name', label: 'Vaccine Name', placeholder: 'e.g. COVID-19' },
Expand All @@ -135,9 +136,17 @@ export default function IssuerDashboard() {
{errors[key] && <p style={styles.fieldError}>{errors[key]}</p>}
</div>
))}
<button style={isAuthorized ? styles.btn : styles.btnDisabled} type="submit" disabled={loading || !isAuthorized} aria-disabled={loading || !isAuthorized}>
{loading ? 'Minting…' : 'Issue Vaccination NFT'}
</button>
<FeedbackButton
style={isAuthorized ? styles.btn : styles.btnDisabled}
onClick={handleSubmit}
disabled={!isAuthorized || !isValid}
loadingLabel="⏳ Minting…"
successLabel="✅ Issued!"
errorLabel="❌ Failed"
aria-label="Issue Vaccination NFT"
>
Issue Vaccination NFT
</FeedbackButton>
</form>
<div aria-live="polite" aria-atomic="true">
{mintResult && (
Expand Down
53 changes: 27 additions & 26 deletions frontend/src/pages/VerifyPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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(() => {
Expand All @@ -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 (
<div style={styles.page}>
<h2 style={{ marginBottom: '1.5rem', color: 'var(--text)' }}>Verify Vaccination Status</h2>
<form onSubmit={handleVerify}>
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="wallet-input" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>
Stellar wallet address
</label>
Expand All @@ -71,9 +59,22 @@ export default function VerifyPage() {
aria-label="Stellar wallet address to verify"
required
/>
<button style={styles.btn} type="submit" disabled={loading} aria-disabled={loading}>
{loading ? 'Checking…' : 'Verify'}
</button>
<FeedbackButton
style={styles.btn}
onClick={() => {
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
</FeedbackButton>
</form>

<div aria-live="polite" aria-atomic="true">
Expand Down