ui(verifications-specific): 💄 Add verification status indicator for admin trust management workflow

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 18:58:39 -07:00
parent 56811226c9
commit d9be7d560c

View file

@ -1,8 +1,9 @@
/**
* Verification Proofs Page
*
* Table of verification proof records: subject ID, type, badge level,
* score, individual proof checkmarks (payment/location/photo), and created date.
* Trust has no list endpoint verifications are looked up per-subject via
* GET /api/trust/verifications/{subjectType}/{subjectId}.
* This page provides the same live lookup form as the overview.
*/
import { useState, useCallback } from 'react'
@ -10,51 +11,41 @@ import { useState, useCallback } from 'react'
import { Card } from '@lilith/ui-primitives'
import styled from '@lilith/ui-styled-components'
import { Heading, Text } from '@lilith/ui-typography'
import { useQuery } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import type { ReactElement, FormEvent } from 'react'
// ─── Types ────────────────────────────────────────────────────────────────────
type TrustBadge = 'NONE' | 'PARTIALLY_VERIFIED' | 'VERIFIED' | 'HIGHLY_VERIFIED' | 'FULLY_VERIFIED'
type SubjectType = 'PROVIDER' | 'CLIENT' | 'USER'
type SubjectType = 'PROVIDER_REVIEW' | 'CLIENT_REVIEW' | 'INTEL_REPORT'
interface VerificationProof {
id: string
subjectId: string
type TrustBadge = 'NONE' | 'PARTIALLY_VERIFIED' | 'VERIFIED' | 'HIGHLY_VERIFIED' | 'FULLY_VERIFIED'
interface VerificationResult {
subjectType: SubjectType
subjectId: string
badge: TrustBadge
score: number
hasPaymentProof: boolean
hasLocationProof: boolean
hasPhotoProof: boolean
createdAt: string
updatedAt: string
}
interface VerificationsResponse {
items: VerificationProof[]
total: number
}
// ─── API ─────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 25
async function fetchVerifications(page: number, limit: number): Promise<VerificationsResponse> {
const offset = (page - 1) * limit
const res = await fetch(`/api/trust/verifications?limit=${limit}&offset=${offset}`)
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json() as Promise<VerificationsResponse>
}
type LookupState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: VerificationResult }
| { status: 'error'; message: string }
// ─── Constants ───────────────────────────────────────────────────────────────
const BADGE_LABELS: Record<TrustBadge, string> = {
NONE: 'None',
PARTIALLY_VERIFIED: 'Partial',
VERIFIED: 'Verified',
HIGHLY_VERIFIED: 'High',
FULLY_VERIFIED: 'Full',
const SUBJECT_TYPES: SubjectType[] = ['PROVIDER_REVIEW', 'CLIENT_REVIEW', 'INTEL_REPORT']
const SUBJECT_TYPE_LABELS: Record<SubjectType, string> = {
PROVIDER_REVIEW: 'PROVIDER_REVIEW',
CLIENT_REVIEW: 'CLIENT_REVIEW',
INTEL_REPORT: 'INTEL_REPORT',
}
const BADGE_COLORS: Record<TrustBadge, string> = {
@ -65,10 +56,15 @@ const BADGE_COLORS: Record<TrustBadge, string> = {
FULLY_VERIFIED: '#22c55e',
}
const SUBJECT_TYPE_LABELS: Record<SubjectType, string> = {
PROVIDER: 'Provider',
CLIENT: 'Client',
USER: 'User',
// ─── API ─────────────────────────────────────────────────────────────────────
async function lookupVerification(
subjectType: SubjectType,
subjectId: string,
): Promise<VerificationResult> {
const res = await fetch(`/api/trust/verifications/${subjectType}/${subjectId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json() as Promise<VerificationResult>
}
// ─── Styled Components ────────────────────────────────────────────────────────
@ -86,9 +82,6 @@ const HeaderSection = styled.div`
const CardHeader = styled.div`
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
`
const CardTitle = styled.h3`
@ -98,172 +91,277 @@ const CardTitle = styled.h3`
`
const CardContent = styled.div`
padding: 1rem;
padding: 1.25rem;
`
const TableContainer = styled.div`
overflow-x: auto;
`
const Table = styled.table`
width: 100%;
border-collapse: collapse;
const InfoText = styled.p`
font-size: 0.875rem;
`
const Th = styled.th`
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
white-space: nowrap;
margin: 0 0 0.75rem;
line-height: 1.6;
`
const Td = styled.td`
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
vertical-align: top;
`
const MonoId = styled.code`
font-size: 0.7rem;
color: var(--text-secondary);
const ApiPath = styled.code`
display: block;
font-size: 0.8125rem;
font-family: monospace;
`
const SubjectTypeTag = styled.span`
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.125rem 0.375rem;
border-radius: 6px;
padding: 0.625rem 0.875rem;
color: var(--text-primary);
margin: 0.75rem 0;
`
const BadgePill = styled.span<{ $badge: TrustBadge }>`
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
color: white;
background: ${({ $badge }): string => BADGE_COLORS[$badge]};
`
const ScoreText = styled.span`
font-weight: 600;
font-variant-numeric: tabular-nums;
`
const CheckMark = styled.span<{ $has: boolean }>`
font-size: 1rem;
color: ${({ $has }): string => ($has ? '#22c55e' : '#6b7280')};
`
const ProofCells = styled.div`
const LookupForm = styled.form`
display: flex;
gap: 0.5rem;
align-items: center;
flex-direction: column;
gap: 1rem;
`
const ProofLabel = styled.span`
font-size: 0.65rem;
const FormRow = styled.div`
display: grid;
grid-template-columns: 220px 1fr auto;
gap: 0.75rem;
align-items: flex-end;
`
const FieldGroup = styled.div`
display: flex;
flex-direction: column;
gap: 0.375rem;
`
const FieldLabel = styled.label`
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
`
const PaginationContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
`
const ResultsCount = styled.span`
font-size: 0.875rem;
color: var(--text-secondary);
`
const PaginationButtons = styled.div`
display: flex;
gap: 0.5rem;
`
const PaginationButton = styled.button`
padding: 0.375rem 0.75rem;
border-radius: 4px;
const Select = styled.select`
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:not(:disabled):hover {
&:focus {
outline: none;
border-color: var(--accent-primary, #8b5cf6);
}
`
const EmptyState = styled.div`
text-align: center;
padding: 3rem;
color: var(--text-secondary);
const Input = styled.input`
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
font-family: monospace;
&:focus {
outline: none;
border-color: var(--accent-primary, #8b5cf6);
}
&::placeholder {
color: var(--text-tertiary, var(--text-secondary));
opacity: 0.6;
}
`
const ErrorState = styled.div`
text-align: center;
padding: 3rem;
const LookupButton = styled.button`
padding: 0.5rem 1.25rem;
border-radius: 6px;
border: none;
background: var(--accent-primary, #8b5cf6);
color: white;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
filter: brightness(1.1);
}
`
const ResultCard = styled.div<{ $status: 'success' | 'error' }>`
border-radius: 6px;
border: 1px solid
${({ $status }): string => ($status === 'success' ? 'var(--border-color)' : '#ef4444')};
background: ${({ $status }): string =>
$status === 'success' ? 'var(--bg-secondary)' : 'rgba(239,68,68,0.08)'};
padding: 1rem;
`
const ErrorText = styled.p`
color: #ef4444;
font-size: 0.875rem;
margin: 0;
`
const ResultGrid = styled.dl`
display: grid;
grid-template-columns: auto 1fr;
gap: 0.375rem 1rem;
margin: 0;
font-size: 0.875rem;
`
const ResultTerm = styled.dt`
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
`
const ResultValue = styled.dd`
color: var(--text-primary);
margin: 0;
font-family: monospace;
`
const BadgePill = styled.span<{ $badge: TrustBadge }>`
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: ${({ $badge }): string => BADGE_COLORS[$badge]};
font-family: sans-serif;
`
const CheckIcon = styled.span<{ $has: boolean }>`
color: ${({ $has }): string => ($has ? '#22c55e' : '#6b7280')};
`
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isKnownBadge(v: string): v is TrustBadge {
return ['NONE', 'PARTIALLY_VERIFIED', 'VERIFIED', 'HIGHLY_VERIFIED', 'FULLY_VERIFIED'].includes(v)
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function isKnownBadge(badge: string): badge is TrustBadge {
return (
badge === 'NONE' ||
badge === 'PARTIALLY_VERIFIED' ||
badge === 'VERIFIED' ||
badge === 'HIGHLY_VERIFIED' ||
badge === 'FULLY_VERIFIED'
)
// ─── Sub-components ───────────────────────────────────────────────────────────
interface LookupResultProps {
state: LookupState
}
function isKnownSubjectType(type: string): type is SubjectType {
return type === 'PROVIDER' || type === 'CLIENT' || type === 'USER'
const LookupResult = ({ state }: LookupResultProps): ReactElement | null => {
if (state.status === 'idle') return null
if (state.status === 'loading') {
return (
<ResultCard $status="success">
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Looking up...
</p>
</ResultCard>
)
}
if (state.status === 'error') {
return (
<ResultCard $status="error">
<ErrorText>{state.message}</ErrorText>
</ResultCard>
)
}
const { data } = state
return (
<ResultCard $status="success">
<ResultGrid>
<ResultTerm>Subject Type</ResultTerm>
<ResultValue>{data.subjectType}</ResultValue>
<ResultTerm>Subject ID</ResultTerm>
<ResultValue>{data.subjectId}</ResultValue>
<ResultTerm>Badge</ResultTerm>
<ResultValue>
{isKnownBadge(data.badge) ? (
<BadgePill $badge={data.badge}>{data.badge}</BadgePill>
) : (
data.badge
)}
</ResultValue>
<ResultTerm>Score</ResultTerm>
<ResultValue>{data.score.toFixed(2)}</ResultValue>
<ResultTerm>Payment Proof</ResultTerm>
<ResultValue>
<CheckIcon $has={data.hasPaymentProof}>
{data.hasPaymentProof ? 'Yes' : 'No'}
</CheckIcon>
</ResultValue>
<ResultTerm>Location Proof</ResultTerm>
<ResultValue>
<CheckIcon $has={data.hasLocationProof}>
{data.hasLocationProof ? 'Yes' : 'No'}
</CheckIcon>
</ResultValue>
<ResultTerm>Photo Proof</ResultTerm>
<ResultValue>
<CheckIcon $has={data.hasPhotoProof}>{data.hasPhotoProof ? 'Yes' : 'No'}</CheckIcon>
</ResultValue>
<ResultTerm>Created</ResultTerm>
<ResultValue>{formatDate(data.createdAt)}</ResultValue>
<ResultTerm>Updated</ResultTerm>
<ResultValue>{formatDate(data.updatedAt)}</ResultValue>
</ResultGrid>
</ResultCard>
)
}
// ─── Component ───────────────────────────────────────────────────────────────
export const VerificationProofsPage = (): ReactElement => {
const [page, setPage] = useState(1)
const [subjectType, setSubjectType] = useState<SubjectType>('PROVIDER_REVIEW')
const [subjectId, setSubjectId] = useState('')
const [lookupState, setLookupState] = useState<LookupState>({ status: 'idle' })
const verificationsQuery = useQuery({
queryKey: ['trust', 'verifications', page],
queryFn: () => fetchVerifications(page, PAGE_SIZE),
placeholderData: (prev) => prev,
})
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
const trimmed = subjectId.trim()
if (!trimmed) return
const handlePrev = useCallback((): void => setPage((p) => Math.max(1, p - 1)), [])
const handleNext = useCallback((): void => setPage((p) => p + 1), [])
const items = verificationsQuery.data?.items ?? []
const total = verificationsQuery.data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
setLookupState({ status: 'loading' })
try {
const data = await lookupVerification(subjectType, trimmed)
setLookupState({ status: 'success', data })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setLookupState({ status: 'error', message: `Lookup failed: ${message}` })
}
},
[subjectType, subjectId],
)
return (
<PageContainer>
@ -272,112 +370,63 @@ export const VerificationProofsPage = (): ReactElement => {
Verification Proofs
</Heading>
<Text size="sm" color="muted">
Review submitted verification proofs and trust badge assignments
Look up trust verification status for a specific review or intel report
</Text>
</HeaderSection>
<Card>
<CardHeader>
<CardTitle>All Verification Proofs</CardTitle>
<CardTitle>Per-Subject Lookup</CardTitle>
</CardHeader>
<TableContainer>
{verificationsQuery.isError ? (
<ErrorState>
Failed to load verifications:{' '}
{verificationsQuery.error instanceof Error
? verificationsQuery.error.message
: 'Unknown error'}
</ErrorState>
) : verificationsQuery.isLoading ? (
<EmptyState>Loading...</EmptyState>
) : items.length === 0 ? (
<EmptyState>No records found</EmptyState>
) : (
<Table>
<thead>
<tr>
<Th>Subject ID</Th>
<Th>Subject Type</Th>
<Th>Badge</Th>
<Th>Score</Th>
<Th>Payment</Th>
<Th>Location</Th>
<Th>Photo</Th>
<Th>Created At</Th>
</tr>
</thead>
<tbody>
{items.map((v) => (
<tr key={v.id}>
<Td>
<MonoId>{v.subjectId}</MonoId>
</Td>
<Td>
<SubjectTypeTag>
{isKnownSubjectType(v.subjectType)
? SUBJECT_TYPE_LABELS[v.subjectType]
: v.subjectType}
</SubjectTypeTag>
</Td>
<Td>
{isKnownBadge(v.badge) ? (
<BadgePill $badge={v.badge}>{BADGE_LABELS[v.badge]}</BadgePill>
) : (
<span>{v.badge}</span>
)}
</Td>
<Td>
<ScoreText>{v.score.toFixed(1)}</ScoreText>
</Td>
<Td>
<ProofCells>
<CheckMark $has={v.hasPaymentProof}>
{v.hasPaymentProof ? '✓' : '✗'}
</CheckMark>
<ProofLabel>Payment</ProofLabel>
</ProofCells>
</Td>
<Td>
<ProofCells>
<CheckMark $has={v.hasLocationProof}>
{v.hasLocationProof ? '✓' : '✗'}
</CheckMark>
<ProofLabel>Location</ProofLabel>
</ProofCells>
</Td>
<Td>
<ProofCells>
<CheckMark $has={v.hasPhotoProof}>
{v.hasPhotoProof ? '✓' : '✗'}
</CheckMark>
<ProofLabel>Photo</ProofLabel>
</ProofCells>
</Td>
<Td>{formatDate(v.createdAt)}</Td>
</tr>
))}
</tbody>
</Table>
)}
</TableContainer>
<CardContent>
<PaginationContainer>
<ResultsCount>
{total > 0
? `Showing ${(page - 1) * PAGE_SIZE + 1}${Math.min(page * PAGE_SIZE, total)} of ${total}`
: 'No results'}
</ResultsCount>
<PaginationButtons>
<PaginationButton disabled={page <= 1} onClick={handlePrev}>
Previous
</PaginationButton>
<PaginationButton disabled={page >= totalPages} onClick={handleNext}>
Next
</PaginationButton>
</PaginationButtons>
</PaginationContainer>
<InfoText>
The trust service has no list endpoint. Verifications are retrieved per-subject via:
</InfoText>
<ApiPath>GET /api/trust/verifications/{'{subjectType}'}/{'{subjectId}'}</ApiPath>
<InfoText>
Enter a subject type and the UUID of the review or report to look up its verification
proof.
</InfoText>
<LookupForm onSubmit={handleSubmit}>
<FormRow>
<FieldGroup>
<FieldLabel htmlFor="vp-subject-type">Subject Type</FieldLabel>
<Select
id="vp-subject-type"
value={subjectType}
onChange={(e): void => setSubjectType(e.target.value as SubjectType)}
>
{SUBJECT_TYPES.map((t) => (
<option key={t} value={t}>
{SUBJECT_TYPE_LABELS[t]}
</option>
))}
</Select>
</FieldGroup>
<FieldGroup>
<FieldLabel htmlFor="vp-subject-id">Subject ID (UUID)</FieldLabel>
<Input
id="vp-subject-id"
type="text"
value={subjectId}
onChange={(e): void => setSubjectId(e.target.value)}
placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000"
spellCheck={false}
/>
</FieldGroup>
<LookupButton
type="submit"
disabled={lookupState.status === 'loading' || !subjectId.trim()}
>
{lookupState.status === 'loading' ? 'Looking up...' : 'Look Up'}
</LookupButton>
</FormRow>
<LookupResult state={lookupState} />
</LookupForm>
</CardContent>
</Card>
</PageContainer>