diff --git a/src/components/CreatorBadges.tsx b/src/components/CreatorBadges.tsx index 4d742be8..54207064 100644 --- a/src/components/CreatorBadges.tsx +++ b/src/components/CreatorBadges.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import type { TrustBadge, TrustTier } from "@/lib/creatorVerification"; // ─── Types ───────────────────────────────────────────────────────────────────── @@ -23,6 +24,8 @@ export interface CreatorStats { reputationScore: number; uniquePayers: number; streakWeeks: number; + trustBadges?: TrustBadge[]; + trustTier?: TrustTier; } // ─── Badge catalogue ─────────────────────────────────────────────────────────── @@ -164,6 +167,47 @@ export const BADGE_DEFINITIONS: BadgeDefinition[] = [ tier: "gold", check: (s) => s.streakWeeks >= 12, }, + // ── Trust badges (mirrors getTrustBadges in src/lib/creatorVerification.ts) ── + { + id: "trust-email", + name: "Email Verified", + description: "Verified your email address.", + icon: "✉️", + tier: "bronze", + check: (s) => !!s.trustBadges?.some((b) => b.id === "email"), + }, + { + id: "trust-wallet", + name: "Wallet Verified", + description: "Connected and verified your Stellar wallet.", + icon: "🔑", + tier: "bronze", + check: (s) => !!s.trustBadges?.some((b) => b.id === "wallet"), + }, + { + id: "trust-kyc", + name: "KYC Verified", + description: "Completed identity verification (KYC).", + icon: "🪪", + tier: "silver", + check: (s) => !!s.trustBadges?.some((b) => b.id === "kyc"), + }, + { + id: "trust-established", + name: "Established Creator", + description: "Has a long-standing, active creator history.", + icon: "🏛️", + tier: "gold", + check: (s) => !!s.trustBadges?.some((b) => b.id === "established"), + }, + { + id: "trust-reliable", + name: "Reliable Creator", + description: "Consistently meets payment and completion expectations.", + icon: "🤝", + tier: "platinum", + check: (s) => !!s.trustBadges?.some((b) => b.id === "reliable"), + }, ]; // ─── Styles ──────────────────────────────────────────────────────────────────── @@ -246,6 +290,9 @@ export default function CreatorBadges({ stats, showLocked = true }: Props) { return counts; }, [earned]); + const trustBadges = useMemo(() => stats.trustBadges ?? [], [stats.trustBadges]); + const trustTier = stats.trustTier; + return (
@@ -268,6 +315,37 @@ export default function CreatorBadges({ stats, showLocked = true }: Props) {
+ {(trustBadges.length > 0 || trustTier) && ( +
+
+

Trust

+ {trustTier && ( + + {trustTier} tier + + )} +
+ {trustBadges.length > 0 ? ( +
+ {trustBadges.map((b) => ( + + + {b.label} + + ))} +
+ ) : ( +

+ No trust badges yet — verify your email and wallet to build trust. +

+ )} +
+ )} + {earned.length === 0 && (

No badges earned yet — keep creating and completing invoices! diff --git a/src/components/CreatorReputationProfile.tsx b/src/components/CreatorReputationProfile.tsx new file mode 100644 index 00000000..4303e0af --- /dev/null +++ b/src/components/CreatorReputationProfile.tsx @@ -0,0 +1,228 @@ +import React, { useState } from 'react'; +import { useCreatorReputation } from '../hooks/useCreatorReputation'; +import CreatorBadges from './CreatorBadges'; +import ReputationAppealForm from './ReputationAppealForm'; + +export interface CreatorReputationProfileProps { + address: string; + className?: string; +} + +interface ReputationBreakdown { + onTimeRate?: number; + completionRate?: number; + disputeRate?: number; + responsiveness?: number; +} + +interface CreatorReputation { + score?: number; + grade?: string; + trustTier?: string; + breakdown?: ReputationBreakdown; + badges?: string[]; + rated?: boolean; +} + +const formatPercent = (value?: number): string => { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; + } + const normalized = value <= 1 ? value * 100 : value; + return `${normalized.toFixed(1)}%`; +}; + +const getScoreColor = (score: number): string => { + if (score >= 80) return '#16a34a'; + if (score >= 60) return '#65a30d'; + if (score >= 40) return '#d97706'; + return '#dc2626'; +}; + +const ScoreGauge: React.FC<{ score: number; grade?: string }> = ({ score, grade }) => { + const clamped = Math.max(0, Math.min(100, score)); + const color = getScoreColor(clamped); + const radius = 52; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (clamped / 100) * circumference; + + return ( +

+ + + + + {Math.round(clamped)} + + + / 100 + + +
+
Grade
+
{grade ?? '—'}
+
+
+ ); +}; + +const BreakdownRow: React.FC<{ label: string; value?: number; invert?: boolean }> = ({ + label, + value, + invert = false, +}) => { + const normalized = value === undefined || value === null ? undefined : value <= 1 ? value * 100 : value; + const barColor = invert + ? normalized !== undefined && normalized > 20 + ? '#dc2626' + : '#16a34a' + : normalized !== undefined && normalized >= 80 + ? '#16a34a' + : normalized !== undefined && normalized >= 50 + ? '#d97706' + : '#dc2626'; + + return ( +
+
+ {label} + {formatPercent(value)} +
+
+
+
+
+ ); +}; + +const CreatorReputationProfile: React.FC = ({ address, className }) => { + const { reputation, isLoading, error } = useCreatorReputation(address) as { + reputation: CreatorReputation | null | undefined; + isLoading: boolean; + error?: Error | null; + }; + const [isAppealOpen, setIsAppealOpen] = useState(false); + + if (isLoading) { + return ( +
+
+ Loading reputation… +
+
+ ); + } + + if (error) { + return ( +
+
+ Unable to load reputation for this creator. +
+
+ ); + } + + const isUnrated = + !reputation || + reputation.rated === false || + reputation.score === undefined || + reputation.score === null; + + if (isUnrated) { + return ( +
+
+

Not yet rated

+

+ This creator has not accumulated enough activity to establish a reputation score. +

+
+ {address} +
+
+
+ ); + } + + const breakdown = reputation.breakdown ?? {}; + + return ( +
+
+

Creator Reputation

+
{address}
+
+ +
+ +
+ +
+
+ Trust Tier +
+
+ {reputation.trustTier ?? 'Unranked'} +
+ +
+ +
+

Breakdown

+ + + + +
+ +
+ +
+ + {isAppealOpen && ( + setIsAppealOpen(false)} + /> + )} +
+ ); +}; + +export default CreatorReputationProfile; \ No newline at end of file diff --git a/src/components/ReputationAppealForm.tsx b/src/components/ReputationAppealForm.tsx new file mode 100644 index 00000000..a5e03428 --- /dev/null +++ b/src/components/ReputationAppealForm.tsx @@ -0,0 +1,391 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + submitReputationAppeal, + getCreatorAppeals, + type ReputationAppeal, + type AppealStatus, +} from '../lib/creatorReputation'; + +export interface ReputationAppealFormProps { + creatorId: string; + isOpen?: boolean; + onClose?: () => void; + onSubmitted?: (appeal: ReputationAppeal) => void; +} + +type FeedbackState = + | { type: 'idle' } + | { type: 'success'; message: string } + | { type: 'error'; message: string }; + +const STATUS_LABELS: Record = { + pending: 'Pending review', + under_review: 'Under review', + approved: 'Approved', + rejected: 'Rejected', +}; + +const STATUS_STYLES: Record = { + pending: 'bg-yellow-100 text-yellow-800', + under_review: 'bg-blue-100 text-blue-800', + approved: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', +}; + +function formatDate(value: string | number | Date | undefined): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +export default function ReputationAppealForm({ + creatorId, + isOpen = true, + onClose, + onSubmitted, +}: ReputationAppealFormProps) { + const [reason, setReason] = useState(''); + const [evidenceUrl, setEvidenceUrl] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [loadingAppeals, setLoadingAppeals] = useState(false); + const [appeals, setAppeals] = useState([]); + const [feedback, setFeedback] = useState({ type: 'idle' }); + const [fieldErrors, setFieldErrors] = useState<{ reason?: string; evidenceUrl?: string }>({}); + + const loadAppeals = useCallback(async () => { + if (!creatorId) return; + setLoadingAppeals(true); + try { + const result = await getCreatorAppeals(creatorId); + setAppeals(Array.isArray(result) ? result : []); + } catch (err) { + setFeedback({ + type: 'error', + message: + err instanceof Error ? err.message : 'Unable to load your existing appeals.', + }); + } finally { + setLoadingAppeals(false); + } + }, [creatorId]); + + useEffect(() => { + if (isOpen) { + void loadAppeals(); + } + }, [isOpen, loadAppeals]); + + const validate = useCallback((): boolean => { + const errors: { reason?: string; evidenceUrl?: string } = {}; + const trimmedReason = reason.trim(); + + if (trimmedReason.length < 20) { + errors.reason = 'Please provide at least 20 characters explaining your appeal.'; + } else if (trimmedReason.length > 2000) { + errors.reason = 'Reason must be 2000 characters or fewer.'; + } + + const trimmedUrl = evidenceUrl.trim(); + if (trimmedUrl && !isValidUrl(trimmedUrl)) { + errors.evidenceUrl = 'Please enter a valid http(s) URL.'; + } + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }, [reason, evidenceUrl]); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + setFeedback({ type: 'idle' }); + + if (!creatorId) { + setFeedback({ type: 'error', message: 'Missing creator identity.' }); + return; + } + + if (!validate()) { + return; + } + + setSubmitting(true); + try { + const trimmedUrl = evidenceUrl.trim(); + const appeal = await submitReputationAppeal({ + creatorId, + reason: reason.trim(), + evidenceUrl: trimmedUrl || undefined, + }); + + setAppeals((prev) => [appeal, ...prev]); + setReason(''); + setEvidenceUrl(''); + setFieldErrors({}); + setFeedback({ + type: 'success', + message: 'Your appeal has been submitted. Our team will review it shortly.', + }); + onSubmitted?.(appeal); + } catch (err) { + setFeedback({ + type: 'error', + message: + err instanceof Error + ? err.message + : 'Something went wrong while submitting your appeal. Please try again.', + }); + } finally { + setSubmitting(false); + } + }, + [creatorId, reason, evidenceUrl, validate, onSubmitted], + ); + + const handleClose = useCallback(() => { + if (submitting) return; + onClose?.(); + }, [submitting, onClose]); + + const handleBackdropClick = useCallback( + (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + handleClose(); + } + }, + [handleClose], + ); + + const reasonCount = reason.trim().length; + const hasPendingAppeal = useMemo( + () => appeals.some((a) => a.status === 'pending' || a.status === 'under_review'), + [appeals], + ); + + if (!isOpen) { + return null; + } + + return ( +
+
+
+
+

+ Dispute your reputation score +

+

+ If you believe your reputation metrics are inaccurate, submit an appeal + with details and supporting evidence. +

+
+ +
+ +
+ {feedback.type !== 'idle' && ( +
+ {feedback.message} +
+ )} + + {hasPendingAppeal && ( +
+ You already have an appeal in progress. You may still submit additional + information below. +
+ )} + +
+ +