diff --git a/src/__tests__/VerifiedCreatorBadge.test.tsx b/src/__tests__/VerifiedCreatorBadge.test.tsx new file mode 100644 index 0000000..0e6543e --- /dev/null +++ b/src/__tests__/VerifiedCreatorBadge.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import VerifiedCreatorBadge, { + formatVerificationDate, +} from "@/components/VerifiedCreatorBadge"; + +const state = vi.hoisted(() => ({ + verified: false, + attestation: null as null | { + address: string; + signature: string; + timestamp?: number; + method?: string; + }, +})); + +vi.mock("@/lib/attestation", () => ({ + isVerifiedCreator: () => state.verified, + getAttestation: () => state.attestation, + getAttestations: () => ({}), + storeAttestation: () => {}, + removeAttestation: () => {}, + generateChallenge: () => "", +})); + +const ADDRESS = "GDVERIFIEDCREATORADDRESS000000000000000000000000000000000"; + +describe("VerifiedCreatorBadge", () => { + beforeEach(() => { + state.verified = false; + state.attestation = null; + }); + + it("renders nothing when the creator is not verified", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("exposes a tooltip with the verification date and method via aria-describedby", () => { + state.verified = true; + state.attestation = { + address: ADDRESS, + signature: "sig", + timestamp: Date.UTC(2024, 0, 15), + method: "on-chain proof", + }; + + render(); + + const badge = screen.getByText(/Verified Creator/i); + const tooltip = screen.getByRole("tooltip"); + + expect(tooltip).toHaveTextContent( + "Verified via on-chain proof on January 15, 2024" + ); + expect(badge).toHaveAttribute("aria-describedby", tooltip.id); + }); + + it("falls back to a generic method when none is recorded", () => { + state.verified = true; + state.attestation = { + address: ADDRESS, + signature: "sig", + timestamp: Date.UTC(2024, 5, 1), + }; + + render(); + + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Verified via signed attestation on June 1, 2024" + ); + }); + + it("renders without a tooltip when no verification date is available", () => { + state.verified = true; + state.attestation = { address: ADDRESS, signature: "sig" }; + + render(); + + expect(screen.getByText(/Verified Creator/i)).toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).toBeNull(); + expect(screen.getByText(/Verified Creator/i)).not.toHaveAttribute( + "aria-describedby" + ); + }); + + it("formats dates in a human-readable form", () => { + expect(formatVerificationDate(Date.UTC(2024, 0, 15))).toBe("January 15, 2024"); + }); +}); diff --git a/src/components/VerifiedCreatorBadge.tsx b/src/components/VerifiedCreatorBadge.tsx index 352261e..3f6dcf9 100644 --- a/src/components/VerifiedCreatorBadge.tsx +++ b/src/components/VerifiedCreatorBadge.tsx @@ -1,31 +1,83 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { isVerifiedCreator } from '@/lib/attestation'; +import { useEffect, useId, useState } from 'react'; +import { getAttestation, isVerifiedCreator } from '@/lib/attestation'; interface Props { address: string; } +/** Format a verification timestamp as a human-readable date, e.g. "January 15, 2024". */ +export function formatVerificationDate(timestamp: number): string { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date(timestamp)); +} + export default function VerifiedCreatorBadge({ address }: Props) { const [isVerified, setIsVerified] = useState(false); const [mounted, setMounted] = useState(false); + const [verification, setVerification] = useState<{ + timestamp: number | null; + method: string | null; + }>({ timestamp: null, method: null }); + + // `useId` contains colons in React 18; strip them so the id is a valid + // `aria-describedby` target. + const tooltipId = `verified-creator-tooltip-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`; useEffect(() => { setMounted(true); - setIsVerified(isVerifiedCreator(address)); + const verified = isVerifiedCreator(address); + setIsVerified(verified); + + if (!verified) { + setVerification({ timestamp: null, method: null }); + return; + } + + const attestation = getAttestation(address); + setVerification({ + timestamp: attestation?.timestamp ?? null, + method: attestation?.method ?? null, + }); }, [address]); if (!mounted || !isVerified) { return null; } + const hasVerificationDate = + typeof verification.timestamp === 'number' && + Number.isFinite(verification.timestamp); + const methodLabel = verification.method?.trim() || 'signed attestation'; + const tooltipText = hasVerificationDate + ? `Verified via ${methodLabel} on ${formatVerificationDate( + verification.timestamp as number, + )}` + : null; + return ( - - ✓ Verified Creator + + + ✓ Verified Creator + + + {tooltipText && ( + + {tooltipText} + + )} ); } diff --git a/src/lib/attestation.ts b/src/lib/attestation.ts index 45f45ca..600a0ef 100644 --- a/src/lib/attestation.ts +++ b/src/lib/attestation.ts @@ -9,6 +9,12 @@ export interface CreatorAttestation { address: string; signature: string; timestamp: number; // Unix timestamp in milliseconds + /** + * Optional human-readable description of how the creator was verified + * (e.g. "on-chain proof", "signed attestation"). Surfaced in the + * VerifiedCreatorBadge tooltip. + */ + method?: string; } const STORAGE_KEY = "stellarsplit_creator_attestations";