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
111 changes: 69 additions & 42 deletions frontend/src/components/NFTCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default function NFTCard({ record, onClick, loading = false }) {

if (loading) return <NFTCardSkeleton count={1} />;

const isRevoked = record.status === 'revoked';

return (
<div
data-testid="nft-card"
Expand All @@ -45,7 +47,7 @@ export default function NFTCard({ record, onClick, loading = false }) {
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick?.()}
style={{
background: '#1e293b',
border: '1px solid #334155',
border: `1px solid ${isRevoked ? '#7f1d1d' : '#334155'}`,
borderRadius: 12,
padding: '1.25rem',
marginBottom: '1rem',
Expand All @@ -55,52 +57,77 @@ export default function NFTCard({ record, onClick, loading = false }) {
boxSizing: 'border-box',
minWidth: 0,
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = '#38bdf8')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = '#334155')}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = isRevoked ? '#f87171' : '#38bdf8')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = isRevoked ? '#7f1d1d' : '#334155')}
>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.1rem', fontWeight: 600, color: '#38bdf8', minWidth: 0, wordBreak: 'break-word' }}>
{/* Header: vaccine name (most prominent) + status badge */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700, color: '#e2e8f0', minWidth: 0, wordBreak: 'break-word' }}>
💉 {record.vaccine_name}
</h3>
<span
data-testid="status-badge"
aria-label={`Status: ${isRevoked ? 'Revoked' : 'Active'}`}
style={{
fontSize: '0.7rem',
fontWeight: 600,
padding: '0.2rem 0.6rem',
borderRadius: 99,
background: isRevoked ? '#7f1d1d' : '#166534',
color: isRevoked ? '#fca5a5' : '#86efac',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{isRevoked ? '✕ Revoked' : '✓ Active'}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{record.dose_number != null && (
<span
aria-label={`Dose ${record.dose_number}${record.dose_series != null ? ` of ${record.dose_series}` : ''}`}
style={{
fontSize: '0.72rem',
fontWeight: 600,
padding: '0.15rem 0.5rem',
borderRadius: 99,
background: record.dose_series != null && record.dose_number >= record.dose_series
? '#166534'
: '#1e3a5f',
color: record.dose_series != null && record.dose_number >= record.dose_series
? '#86efac'
: '#93c5fd',
whiteSpace: 'nowrap',
}}
>
{record.dose_series != null
? `${record.dose_number}/${record.dose_series} doses`
: `Dose ${record.dose_number}`}
</span>
)}
<span style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'inline-flex', alignItems: 'center' }} aria-label={`Token ID ${record.token_id}`}>
#{record.token_id}
<CopyButton text={String(record.token_id)} label="token ID" />
</span>
</div>
</div>
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem', fontSize: '0.9rem' }}>
Date: {record.date_administered}
</p>
<p style={{ color: '#94a3b8', fontSize: '0.8rem', marginTop: '0.25rem' }}>
Issuer: <Tooltip text={record.issuer} position="top">
<span style={{ cursor: 'help', borderBottom: '1px dotted #94a3b8' }}>
{record.issuer?.slice(0, 8)}…{record.issuer?.slice(-4)}

{/* Dose + token ID */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
{record.dose_number != null && (
<span
aria-label={`Dose ${record.dose_number}${record.dose_series != null ? ` of ${record.dose_series}` : ''}`}
style={{
fontSize: '0.72rem',
fontWeight: 600,
padding: '0.15rem 0.5rem',
borderRadius: 99,
background: record.dose_series != null && record.dose_number >= record.dose_series
? '#166534'
: '#1e3a5f',
color: record.dose_series != null && record.dose_number >= record.dose_series
? '#86efac'
: '#93c5fd',
whiteSpace: 'nowrap',
}}
>
{record.dose_series != null
? `${record.dose_number}/${record.dose_series} doses`
: `Dose ${record.dose_number}`}
</span>
</Tooltip>
</p>
)}
<span style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'inline-flex', alignItems: 'center' }} aria-label={`Token ID ${record.token_id}`}>
#{record.token_id}
<CopyButton text={String(record.token_id)} label="token ID" />
</span>
</div>

{/* Labelled meta fields */}
<dl style={{ margin: '0.75rem 0 0', display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.25rem 0.75rem', fontSize: '0.85rem' }}>
<dt style={{ color: '#64748b', fontWeight: 500 }}>Date</dt>
<dd style={{ margin: 0, color: '#cbd5e1' }}>{record.date_administered}</dd>

<dt style={{ color: '#64748b', fontWeight: 500 }}>Issuer</dt>
<dd style={{ margin: 0, color: '#94a3b8' }}>
<Tooltip text={record.issuer} position="top">
<span style={{ cursor: 'help', borderBottom: '1px dotted #94a3b8' }}>
{record.issuer?.slice(0, 8)}…{record.issuer?.slice(-4)}
</span>
</Tooltip>
</dd>
</dl>

<button
aria-label={`Export certificate for ${record.vaccine_name}`}
onClick={(e) => { e.stopPropagation(); exportCertificate(record); }}
Expand Down
90 changes: 51 additions & 39 deletions frontend/src/components/NFTCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('NFTCard', () => {
token_id: '12345',
vaccine_name: 'COVID-19',
date_administered: '2024-01-15',
issuer: 'GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678901234'
issuer: 'GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678901234',
};

const mockOnClick = jest.fn();
Expand All @@ -18,82 +18,94 @@ describe('NFTCard', () => {
mockOnClick.mockClear();
});

it('should render NFT details correctly', () => {
it('renders vaccine name as the most prominent heading', () => {
render(<NFTCard record={mockRecord} onClick={mockOnClick} />);

expect(screen.getByText('💉 COVID-19')).toBeInTheDocument();
expect(screen.getByText('#12345')).toBeInTheDocument();
expect(screen.getByText('Date: 2024-01-15')).toBeInTheDocument();
expect(screen.getByText(/Issuer: GABC1234/)).toBeInTheDocument();
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('COVID-19');
});

it('should handle click events', () => {
render(<NFTCard record={mockRecord} onClick={mockOnClick} />);
it('renders date with label', () => {
render(<NFTCard record={mockRecord} />);
expect(screen.getByText('Date')).toBeInTheDocument();
expect(screen.getByText('2024-01-15')).toBeInTheDocument();
});

const card = screen.getByTestId('nft-card');
fireEvent.click(card);
it('renders issuer with label', () => {
render(<NFTCard record={mockRecord} />);
expect(screen.getByText('Issuer')).toBeInTheDocument();
expect(screen.getByText(/GABC1234/)).toBeInTheDocument();
});

expect(mockOnClick).toHaveBeenCalledTimes(1);
it('shows Active status badge by default', () => {
render(<NFTCard record={mockRecord} />);
const badge = screen.getByTestId('status-badge');
expect(badge).toHaveTextContent('Active');
expect(badge).toHaveAttribute('aria-label', 'Status: Active');
});

it('should handle keyboard events (Enter key)', () => {
render(<NFTCard record={mockRecord} onClick={mockOnClick} />);
it('shows Revoked status badge when status is revoked', () => {
render(<NFTCard record={{ ...mockRecord, status: 'revoked' }} />);
const badge = screen.getByTestId('status-badge');
expect(badge).toHaveTextContent('Revoked');
expect(badge).toHaveAttribute('aria-label', 'Status: Revoked');
});

const card = screen.getByTestId('nft-card');
fireEvent.keyDown(card, { key: 'Enter' });
it('renders token ID', () => {
render(<NFTCard record={mockRecord} />);
expect(screen.getByText('#12345')).toBeInTheDocument();
});

it('handles click events', () => {
render(<NFTCard record={mockRecord} onClick={mockOnClick} />);
fireEvent.click(screen.getByTestId('nft-card'));
expect(mockOnClick).toHaveBeenCalledTimes(1);
});

it('should handle keyboard events (Space key)', () => {
it('handles Enter key', () => {
render(<NFTCard record={mockRecord} onClick={mockOnClick} />);
fireEvent.keyDown(screen.getByTestId('nft-card'), { key: 'Enter' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});

const card = screen.getByTestId('nft-card');
fireEvent.keyDown(card, { key: ' ' });

it('handles Space key', () => {
render(<NFTCard record={mockRecord} onClick={mockOnClick} />);
fireEvent.keyDown(screen.getByTestId('nft-card'), { key: ' ' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});

it('should render without onClick handler', () => {
it('renders without onClick handler', () => {
render(<NFTCard record={mockRecord} />);

const card = screen.getByTestId('nft-card');
expect(card).toBeInTheDocument();
expect(screen.getByTestId('nft-card')).toBeInTheDocument();
});

it('should display truncated issuer address', () => {
it('displays truncated issuer address', () => {
render(<NFTCard record={mockRecord} />);

expect(screen.getByText(/Issuer: GABC1234/)).toBeInTheDocument();
expect(screen.getByText(/GABC1234/)).toBeInTheDocument();
expect(screen.getByText(/…1234$/)).toBeInTheDocument();
});

it('should display dose progress badge when dose_number and dose_series are present', () => {
const doseRecord = { ...mockRecord, dose_number: 2, dose_series: 3 };
render(<NFTCard record={doseRecord} />);
it('displays dose progress badge when dose_number and dose_series are present', () => {
render(<NFTCard record={{ ...mockRecord, dose_number: 2, dose_series: 3 }} />);
expect(screen.getByText('2/3 doses')).toBeInTheDocument();
expect(screen.getByLabelText('Dose 2 of 3')).toBeInTheDocument();
});

it('should display completed dose badge when series is complete', () => {
const doseRecord = { ...mockRecord, dose_number: 3, dose_series: 3 };
render(<NFTCard record={doseRecord} />);
it('displays completed dose badge when series is complete', () => {
render(<NFTCard record={{ ...mockRecord, dose_number: 3, dose_series: 3 }} />);
expect(screen.getByText('3/3 doses')).toBeInTheDocument();
});

it('should display dose number only when dose_series is absent', () => {
const doseRecord = { ...mockRecord, dose_number: 1 };
render(<NFTCard record={doseRecord} />);
it('displays dose number only when dose_series is absent', () => {
render(<NFTCard record={{ ...mockRecord, dose_number: 1 }} />);
expect(screen.getByText('Dose 1')).toBeInTheDocument();
});

it('should not display dose badge when dose_number is absent', () => {
it('does not display dose badge when dose_number is absent', () => {
render(<NFTCard record={mockRecord} />);
expect(screen.queryByText(/doses/)).not.toBeInTheDocument();
expect(screen.queryByText(/Dose \d/)).not.toBeInTheDocument();
});

// Edge case / null value tests — Closes #345
it('renders without crashing when issuer is null', () => {
render(<NFTCard record={{ ...mockRecord, issuer: null }} />);
expect(screen.getByTestId('nft-card')).toBeInTheDocument();
Expand All @@ -108,4 +120,4 @@ describe('NFTCard', () => {
render(<NFTCard record={{ ...mockRecord, patient: null }} />);
expect(screen.getByTestId('nft-card')).toBeInTheDocument();
});
});
});