From a2121dcd2608ebbe3b4b95070c23903f06b1a9c3 Mon Sep 17 00:00:00 2001
From: waterWang <672684719@qq.com>
Date: Sat, 22 Aug 2026 20:26:30 +0800
Subject: [PATCH] feat: add vaccination proof generation UI (Closes #62)
- Create VaccinationProofGenerator component with credential selection
- Proof parameter configuration (duration, verification level, toggles)
- Proof generation with loading state and error handling
- Generated proof display with verification hash, copy/download
- Progress status and expiration tracking
- 12 tests for proof generation component
- 51/51 tests passing
---
.../vaccination-proof-generator.test.tsx | 96 ++++
.../components/loading/loading-spinner.tsx | 44 ++
.../vaccination-proof-generator.tsx | 421 ++++++++++++++++++
3 files changed, 561 insertions(+)
create mode 100644 frontend/__tests__/vaccination-proof-generator.test.tsx
create mode 100644 frontend/src/components/loading/loading-spinner.tsx
create mode 100644 frontend/src/components/vaccination-proof-generator.tsx
diff --git a/frontend/__tests__/vaccination-proof-generator.test.tsx b/frontend/__tests__/vaccination-proof-generator.test.tsx
new file mode 100644
index 00000000..54111072
--- /dev/null
+++ b/frontend/__tests__/vaccination-proof-generator.test.tsx
@@ -0,0 +1,96 @@
+import { render, screen } from '@testing-library/react';
+import { VaccinationProofGenerator } from '../src/components/vaccination-proof-generator';
+import { AccessibilityProvider } from '../src/contexts/AccessibilityContext';
+
+const MOCK_CREDENTIALS = [
+ { id: 'cred-1', vaccineType: 'COVID-19 (Pfizer)', verificationStatus: true, vaccinationDate: '2026-01-15' },
+ { id: 'cred-2', vaccineType: 'Influenza 2025', verificationStatus: false, vaccinationDate: '2025-09-20' },
+];
+
+function renderGenerator(credentials = MOCK_CREDENTIALS) {
+ return render(
+
+
+
+ );
+}
+
+describe('VaccinationProofGenerator', () => {
+ it('renders section heading', () => {
+ renderGenerator();
+ expect(screen.getByText('Vaccination Proof Generator')).toBeInTheDocument();
+ });
+
+ it('renders credential selection dropdown', () => {
+ renderGenerator();
+ expect(screen.getByLabelText('Select Vaccination Credential')).toBeInTheDocument();
+ });
+
+ it('renders credential options', () => {
+ renderGenerator();
+ expect(screen.getByText('COVID-19 (Pfizer) — Verified')).toBeInTheDocument();
+ expect(screen.getByText('Influenza 2025 — Pending')).toBeInTheDocument();
+ });
+
+ it('renders proof duration selector', () => {
+ renderGenerator();
+ expect(screen.getByLabelText('Proof Duration')).toBeInTheDocument();
+ expect(screen.getByText('1 hour')).toBeInTheDocument();
+ expect(screen.getByText('1 day')).toBeInTheDocument();
+ expect(screen.getByText('1 week')).toBeInTheDocument();
+ expect(screen.getByText('1 month')).toBeInTheDocument();
+ expect(screen.getByText('1 year')).toBeInTheDocument();
+ });
+
+ it('renders verification level selector', () => {
+ renderGenerator();
+ expect(screen.getByLabelText('Verification Level')).toBeInTheDocument();
+ expect(screen.getByText('Basic — Status only')).toBeInTheDocument();
+ expect(screen.getByText('Advanced — Status + issuer')).toBeInTheDocument();
+ expect(screen.getByText('Full — All credential data')).toBeInTheDocument();
+ });
+
+ it('renders toggle options', () => {
+ renderGenerator();
+ expect(screen.getByText('Include issuer information')).toBeInTheDocument();
+ expect(screen.getByText('Include vaccination date')).toBeInTheDocument();
+ });
+
+ it('renders generate button', () => {
+ renderGenerator();
+ const buttons = screen.getAllByText('Generate Proof');
+ expect(buttons.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('shows no credentials message when empty', () => {
+ renderGenerator([]);
+ expect(screen.getByText('No credentials available. Upload a vaccination record first.')).toBeInTheDocument();
+ });
+
+ it('renders generate button disabled when no credential selected', () => {
+ renderGenerator();
+ // Default state has no credential selected, so button should be disabled
+ const buttons = screen.getAllByText('Generate Proof');
+ const button = buttons.find((el) => el.tagName === 'BUTTON');
+ expect(button).toBeDisabled();
+ });
+
+ it('renders duration options in correct order', () => {
+ renderGenerator();
+ const select = screen.getByLabelText('Proof Duration') as HTMLSelectElement;
+ expect(select.value).toBe('86400'); // 1 day default
+ });
+
+ it('renders with empty credentials array', () => {
+ renderGenerator([]);
+ const select = screen.getByLabelText('Select Vaccination Credential') as HTMLSelectElement;
+ expect(select).toBeInTheDocument();
+ expect(select.options.length).toBe(1); // Only the placeholder
+ });
+
+ it('renders accessible region', () => {
+ renderGenerator();
+ const region = screen.getByRole('region', { name: 'Vaccination Proof Generator' });
+ expect(region).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/loading/loading-spinner.tsx b/frontend/src/components/loading/loading-spinner.tsx
new file mode 100644
index 00000000..9d932067
--- /dev/null
+++ b/frontend/src/components/loading/loading-spinner.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { motion } from 'framer-motion';
+
+interface LoadingSpinnerProps {
+ size?: 'sm' | 'md' | 'lg';
+ label?: string;
+ className?: string;
+}
+
+const SIZE_CLASSES = {
+ sm: 'w-4 h-4 border-2',
+ md: 'w-8 h-8 border-[3px]',
+ lg: 'w-12 h-12 border-4',
+} as const;
+
+export function LoadingSpinner({ size = 'md', label, className = '' }: LoadingSpinnerProps) {
+ const spinner = (
+
+ {label || 'Loading...'}
+
+ );
+
+ if (!label) return (
+
+ {spinner}
+
+ );
+
+ return (
+
+ {spinner}
+ {label}
+
+ );
+}
+
+export { LoadingSpinner as default };
diff --git a/frontend/src/components/vaccination-proof-generator.tsx b/frontend/src/components/vaccination-proof-generator.tsx
new file mode 100644
index 00000000..0b4d6a86
--- /dev/null
+++ b/frontend/src/components/vaccination-proof-generator.tsx
@@ -0,0 +1,421 @@
+'use client';
+
+import { useState, useCallback, useRef } from 'react';
+import { Shield, FileText, Calendar, Clock, CheckCircle, Copy, Download, Share2, Loader2 } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SuccessCheckmark } from './animations/success-checkmark';
+import { LoadingSpinner } from './loading/loading-spinner';
+import { useAccessibility } from '@/contexts/AccessibilityContext';
+
+interface Credential {
+ id: string;
+ vaccineType: string;
+ verificationStatus: boolean;
+ vaccinationDate: string;
+}
+
+interface GeneratedProof {
+ id: string;
+ credentialId: string;
+ vaccineType: string;
+ parameters: ProofParameters;
+ status: 'generating' | 'valid' | 'expired';
+ createdAt: string;
+ expiresAt: string;
+ verificationHash: string;
+}
+
+interface ProofParameters {
+ duration: number;
+ includeIssuer: boolean;
+ includeDate: boolean;
+ verificationLevel: 'basic' | 'advanced' | 'full';
+}
+
+const DURATION_OPTIONS = [
+ { value: 3600, label: '1 hour' },
+ { value: 86400, label: '1 day' },
+ { value: 604800, label: '1 week' },
+ { value: 2592000, label: '1 month' },
+ { value: 31536000, label: '1 year' },
+];
+
+const VERIFICATION_LEVELS = [
+ { value: 'basic' as const, label: 'Basic', description: 'Status only' },
+ { value: 'advanced' as const, label: 'Advanced', description: 'Status + issuer' },
+ { value: 'full' as const, label: 'Full', description: 'All credential data' },
+];
+
+interface VaccinationProofGeneratorProps {
+ walletAddress: string;
+ credentials?: Credential[];
+}
+
+export function VaccinationProofGenerator({ walletAddress, credentials = [] }: VaccinationProofGeneratorProps) {
+ const [selectedCredentialId, setSelectedCredentialId] = useState('');
+ const [parameters, setParameters] = useState({
+ duration: 86400,
+ includeIssuer: true,
+ includeDate: true,
+ verificationLevel: 'basic',
+ });
+ const [generatedProof, setGeneratedProof] = useState(null);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const { announceToScreenReader } = useAccessibility();
+ const proofRef = useRef(null);
+
+ const selectedCredential = credentials.find((c) => c.id === selectedCredentialId);
+
+ const handleGenerate = useCallback(async () => {
+ if (!selectedCredentialId || isGenerating) return;
+ setIsGenerating(true);
+ setError(null);
+ setGeneratedProof(null);
+ announceToScreenReader('Generating vaccination proof...');
+
+ try {
+ // Simulate async proof generation
+ await new Promise((resolve, reject) => {
+ setTimeout(() => {
+ // 5% chance of failure for demo
+ if (Math.random() < 0.05) {
+ reject(new Error('Proof generation failed. Please try again.'));
+ return;
+ }
+
+ const now = Date.now();
+ const proof: GeneratedProof = {
+ id: crypto.randomUUID(),
+ credentialId: selectedCredentialId,
+ vaccineType: selectedCredential?.vaccineType || 'Unknown',
+ parameters: { ...parameters },
+ status: 'valid',
+ createdAt: new Date(now).toISOString(),
+ expiresAt: new Date(now + parameters.duration * 1000).toISOString(),
+ verificationHash: Array.from({ length: 64 }, () =>
+ Math.floor(Math.random() * 16).toString(16)
+ ).join(''),
+ };
+ setGeneratedProof(proof);
+ announceToScreenReader('Proof generated successfully');
+ resolve();
+ }, 1500);
+ });
+ } catch (e) {
+ const message = e instanceof Error ? e.message : 'Proof generation failed';
+ setError(message);
+ announceToScreenReader(message);
+ } finally {
+ setIsGenerating(false);
+ }
+ }, [selectedCredentialId, selectedCredential, parameters, isGenerating, announceToScreenReader]);
+
+ const handleCopyHash = useCallback(() => {
+ if (generatedProof) {
+ navigator.clipboard.writeText(generatedProof.verificationHash).then(() => {
+ setCopied(true);
+ announceToScreenReader('Verification hash copied to clipboard');
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }
+ }, [generatedProof, announceToScreenReader]);
+
+ const handleDownload = useCallback(() => {
+ if (!generatedProof) return;
+ const proofData = JSON.stringify(generatedProof, null, 2);
+ const blob = new Blob([proofData], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `vaccination-proof-${generatedProof.id.slice(0, 8)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ announceToScreenReader('Proof downloaded');
+ }, [generatedProof, announceToScreenReader]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent, action: () => void) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ action();
+ }
+ },
+ []
+ );
+
+ const formatDate = (iso: string) => new Date(iso).toLocaleString();
+ const getTimeRemaining = (expiresAt: string) => {
+ const remaining = new Date(expiresAt).getTime() - Date.now();
+ if (remaining <= 0) return 'Expired';
+ const hours = Math.floor(remaining / 3600000);
+ const minutes = Math.floor((remaining % 3600000) / 60000);
+ return `${hours}h ${minutes}m remaining`;
+ };
+
+ return (
+
+
+ Vaccination Proof Generator
+
+
+ {/* Proof form */}
+
+
Generate Proof
+
+
+ {/* Credential selection */}
+
+
+
+ {credentials.length === 0 && (
+
+ No credentials available. Upload a vaccination record first.
+
+ )}
+
+
+ {/* Parameter configuration */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Toggle options */}
+
+
+
+
+
+ {/* Error message */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Generate button */}
+
+ {isGenerating ? (
+
+ ) : (
+ <>
+
+ Generate Proof
+ >
+ )}
+
+
+
+
+ {/* Generated proof display */}
+
+ {generatedProof && (
+
+
+
+
+ Proof Generated
+
+
+ {generatedProof.status === 'valid' ? 'Valid' : 'Expired'}
+
+
+
+ {/* Proof details */}
+
+
+
+
+
{generatedProof.vaccineType}
+
+
+
+
+ {generatedProof.parameters.verificationLevel}
+
+
+
+
+
+
+ {formatDate(generatedProof.createdAt)}
+
+
+
+
+
+
+ {formatDate(generatedProof.expiresAt)}
+
+
+
+
+ {/* Verification hash */}
+
+
+
+
+ {generatedProof.verificationHash}
+
+
+
+
+
+ {/* Parameters config display */}
+
+
+
+
+ {generatedProof.parameters.includeIssuer ? 'Includes issuer' : 'No issuer'}
+
+
+ {generatedProof.parameters.includeDate ? 'Includes date' : 'No date'}
+
+
+ {getTimeRemaining(generatedProof.expiresAt)}
+
+
+
+
+
+ {/* Action buttons */}
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+export { VaccinationProofGenerator as default };
\ No newline at end of file