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
90 changes: 90 additions & 0 deletions src/__tests__/VerifiedCreatorBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<VerifiedCreatorBadge address={ADDRESS} />);
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(<VerifiedCreatorBadge address={ADDRESS} />);

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(<VerifiedCreatorBadge address={ADDRESS} />);

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(<VerifiedCreatorBadge address={ADDRESS} />);

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");
});
});
68 changes: 60 additions & 8 deletions src/components/VerifiedCreatorBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900 text-green-300"
title="This creator has verified their identity"
>
✓ Verified Creator
<span className="relative inline-flex group">
<span
tabIndex={0}
aria-describedby={tooltipText ? tooltipId : undefined}
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900 text-green-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
>
✓ Verified Creator
</span>

{tooltipText && (
<span
role="tooltip"
id={tooltipId}
className="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-100 shadow-lg opacity-0 invisible transition-opacity duration-150 group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible"
>
{tooltipText}
</span>
)}
</span>
);
}
6 changes: 6 additions & 0 deletions src/lib/attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down