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
93 changes: 51 additions & 42 deletions frontend/src/components/VerificationBadge.jsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,76 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

const LoadingSpinner = () => (
<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' }}
>
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2.5,8.5 6.5,12.5 13.5,4.5" />
</svg>
);

const XIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="4" y1="4" x2="12" y2="12" />
<line x1="12" y1="4" x2="4" y2="12" />
</svg>
);

const SpinnerIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"
stroke="currentColor" strokeWidth="3" strokeLinecap="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>
);

// Colors chosen for WCAG AA contrast (≥4.5:1) against white text in both light and dark modes.
// verified: #15803d on white bg → 5.74:1 ✓ | not-verified: #b91c1c on white bg → 5.08:1 ✓
// revoked: #b91c1c | loading: #1d4ed8 → 5.9:1 ✓
const CONFIGS = {
verified: { bg: '#15803d', label: 'badge.verified' },
'not-found': { bg: '#b91c1c', label: 'badge.notVerified' },
revoked: { bg: '#b91c1c', label: 'badge.revoked' },
loading: { bg: '#1d4ed8', label: 'badge.verifying' },
};

const ICONS = {
verified: <CheckIcon />,
'not-found': <XIcon />,
revoked: <XIcon />,
loading: <SpinnerIcon />,
};

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: 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: t('badge.notFound'),
icon: '?',
},
revoked: {
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: t('badge.verifying'),
icon: <LoadingSpinner />,
},
};

let effectiveStatus = status;
if (!effectiveStatus && typeof vaccinated !== 'undefined') {
effectiveStatus = vaccinated ? 'verified' : 'not-found';
}
const config = configs[effectiveStatus] || configs['not-found'];
const key = effectiveStatus in CONFIGS ? effectiveStatus : 'not-found';
const { bg, label: labelKey } = CONFIGS[key];
const label = labelKey === 'badge.verified'
? t('badge.verified', { count: recordCount })
: t(labelKey);

return (
<div
data-testid="verification-badge"
id="verification-badge"
aria-label={config.label}
role="status"
aria-label={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.5rem',
padding: '0.375rem 0.875rem', minHeight: '2rem', borderRadius: '12px',
backgroundColor: bg, color: '#ffffff',
fontSize: '0.875rem', fontWeight: '600',
transition: 'background-color 0.2s ease', cursor: 'default',
}}
>
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '18px', height: '18px', fontSize: '1rem' }}>
{config.icon}
</span>
<span>{config.label}</span>
<span style={{ display: 'flex', alignItems: 'center' }}>{ICONS[key]}</span>
<span>{label}</span>
</div>
);
}
130 changes: 56 additions & 74 deletions frontend/src/components/VerificationBadge.test.jsx
Original file line number Diff line number Diff line change
@@ -1,127 +1,109 @@
import { render, screen } from '@testing-library/react';
import VerificationBadge from './VerificationBadge';

describe('VerificationBadge', () => {
it('should render verified status with record count', () => {
render(<VerificationBadge status="verified" vaccinated={true} recordCount={3} />);
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, opts) => {
const map = {
'badge.verified': `Verified: ${opts?.count ?? 0} Record${opts?.count !== 1 ? 's' : ''}`,
'badge.notVerified': 'Not Verified',
'badge.notFound': 'No Records Found',
'badge.revoked': 'Certificate Revoked',
'badge.verifying': 'Verifying Status...',
};
return map[key] ?? key;
},
}),
}));

const badge = screen.getByTestId('verification-badge');
expect(badge).toBeInTheDocument();
describe('VerificationBadge', () => {
it('renders verified status with record count', () => {
render(<VerificationBadge status="verified" recordCount={3} />);
expect(screen.getByTestId('verification-badge')).toBeInTheDocument();
expect(screen.getByText('Verified: 3 Records')).toBeInTheDocument();
expect(screen.getByText('✓')).toBeInTheDocument();
});

it('should render verified status with singular record', () => {
render(<VerificationBadge status="verified" vaccinated={true} recordCount={1} />);

it('renders verified status with singular record', () => {
render(<VerificationBadge status="verified" recordCount={1} />);
expect(screen.getByText('Verified: 1 Record')).toBeInTheDocument();
});

it('should render not-found status when no records', () => {
render(<VerificationBadge status="not-found" vaccinated={false} />);

expect(screen.getByText('No Records Found')).toBeInTheDocument();
expect(screen.getByText('?')).toBeInTheDocument();
it('renders not-found status with "Not Verified" label', () => {
render(<VerificationBadge status="not-found" />);
expect(screen.getByText('Not Verified')).toBeInTheDocument();
});

it('should render revoked status', () => {
it('renders revoked status', () => {
render(<VerificationBadge status="revoked" />);

expect(screen.getByText('Certificate Revoked')).toBeInTheDocument();
expect(screen.getByText('✕')).toBeInTheDocument();
});

it('should render loading status', () => {
it('renders loading status', () => {
render(<VerificationBadge status="loading" />);

expect(screen.getByText('Verifying Status...')).toBeInTheDocument();
expect(screen.getByTestId('verification-badge')).toBeInTheDocument();
});

it('should default to verified when vaccinated is true without status', () => {
it('defaults to verified when vaccinated=true without status', () => {
render(<VerificationBadge vaccinated={true} />);

expect(screen.getByText('Verified: 0 Records')).toBeInTheDocument();
});

it('should default to not-found when vaccinated is false without status', () => {
it('defaults to not-found when vaccinated=false without status', () => {
render(<VerificationBadge vaccinated={false} />);
expect(screen.getByText('Not Verified')).toBeInTheDocument();
});

expect(screen.getByText('No Records Found')).toBeInTheDocument();
it('renders unknown status as not-found', () => {
render(<VerificationBadge status="unknown-status" />);
expect(screen.getByText('Not Verified')).toBeInTheDocument();
});

it('should apply correct styling for verified status', () => {
it('verified badge uses green background', () => {
render(<VerificationBadge status="verified" />);
expect(screen.getByTestId('verification-badge')).toHaveStyle({ backgroundColor: '#15803d' });
});

const badge = screen.getByTestId('verification-badge');
expect(badge).toHaveStyle({ color: '#16a34a' });
it('not-found badge uses red background', () => {
render(<VerificationBadge status="not-found" />);
expect(screen.getByTestId('verification-badge')).toHaveStyle({ backgroundColor: '#b91c1c' });
});

it('should apply correct styling for revoked status', () => {
it('revoked badge uses red background', () => {
render(<VerificationBadge status="revoked" />);

const badge = screen.getByTestId('verification-badge');
expect(badge).toHaveStyle({ color: '#dc2626' });
expect(screen.getByTestId('verification-badge')).toHaveStyle({ backgroundColor: '#b91c1c' });
});

it('should apply correct styling for not-found status', () => {
render(<VerificationBadge status="not-found" />);

const badge = screen.getByTestId('verification-badge');
expect(badge).toHaveStyle({ color: '#64748b' });
it('badge uses white text for contrast', () => {
render(<VerificationBadge status="verified" />);
expect(screen.getByTestId('verification-badge')).toHaveStyle({ color: '#ffffff' });
});

it('should apply correct styling for loading status', () => {
render(<VerificationBadge status="loading" />);

const badge = screen.getByTestId('verification-badge');
expect(badge).toHaveStyle({ color: '#2563eb' });
it('badge meets minimum height (minHeight 2rem)', () => {
render(<VerificationBadge status="verified" />);
expect(screen.getByTestId('verification-badge')).toHaveStyle({ minHeight: '2rem' });
});

it('should render unknown status as not-found', () => {
render(<VerificationBadge status="unknown-status" />);

expect(screen.getByText('No Records Found')).toBeInTheDocument();
it('badge has role="status" for accessibility', () => {
render(<VerificationBadge status="verified" recordCount={2} />);
expect(screen.getByRole('status')).toBeInTheDocument();
});

// Accessibility and ARIA tests — Closes #343
it('badge has aria-label when verified', () => {
render(<VerificationBadge status="verified" recordCount={2} />);
const badge = screen.getByTestId('verification-badge');
expect(badge).toHaveAttribute('aria-label');
expect(badge.getAttribute('aria-label')).toBeTruthy();
expect(badge.getAttribute('aria-label')).toMatch(/verif/i);
});

it('badge has aria-label when not verified', () => {
render(<VerificationBadge vaccinated={false} />);
const badge = screen.getByTestId('verification-badge');
expect(badge).toHaveAttribute('aria-label');
expect(badge.getAttribute('aria-label')).toBeTruthy();
});

it('renders correctly with record details (recordCount > 0)', () => {
render(<VerificationBadge status="verified" vaccinated={true} recordCount={5} />);
expect(screen.getByText('Verified: 5 Records')).toBeInTheDocument();
});

it('renders correctly without record details (recordCount = 0)', () => {
render(<VerificationBadge status="verified" vaccinated={true} recordCount={0} />);
expect(screen.getByTestId('verification-badge')).toBeInTheDocument();
expect(screen.getByText('✓')).toBeInTheDocument();
});

it('verified badge aria-label contains meaningful text', () => {
render(<VerificationBadge status="verified" recordCount={3} />);
const badge = screen.getByTestId('verification-badge');
const label = badge.getAttribute('aria-label');
// Should reference verification state
expect(label.toLowerCase()).toMatch(/verif/);
});

it('not-found badge aria-label contains meaningful text', () => {
render(<VerificationBadge status="not-found" />);
const badge = screen.getByTestId('verification-badge');
const label = badge.getAttribute('aria-label');
expect(label).toBeTruthy();
it('SVG icons have aria-hidden to avoid duplicate announcements', () => {
const { container } = render(<VerificationBadge status="verified" />);
container.querySelectorAll('svg').forEach(svg =>
expect(svg).toHaveAttribute('aria-hidden', 'true')
);
});
});
});
1 change: 1 addition & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"verified": "Verified: {{count}} Record",
"verified_other": "Verified: {{count}} Records",
"notFound": "No Records Found",
"notVerified": "Not Verified",
"revoked": "Certificate Revoked",
"verifying": "Verifying Status..."
},
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"verified": "Vérifié : {{count}} dossier",
"verified_other": "Vérifié : {{count}} dossiers",
"notFound": "Aucun dossier trouvé",
"notVerified": "Non vérifié",
"revoked": "Certificat révoqué",
"verifying": "Vérification en cours..."
},
Expand Down
Loading
Loading