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