Skip to content
Open
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
78 changes: 78 additions & 0 deletions src/components/CreatorBadges.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useMemo } from "react";
import type { TrustBadge, TrustTier } from "@/lib/creatorVerification";

// ─── Types ─────────────────────────────────────────────────────────────────────

Expand All @@ -23,6 +24,8 @@ export interface CreatorStats {
reputationScore: number;
uniquePayers: number;
streakWeeks: number;
trustBadges?: TrustBadge[];
trustTier?: TrustTier;
}

// ─── Badge catalogue ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 (
<section aria-labelledby="badges-heading" className="bg-gray-900 border border-gray-800 rounded-2xl p-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
Expand All @@ -268,6 +315,37 @@ export default function CreatorBadges({ stats, showLocked = true }: Props) {
</div>
</div>

{(trustBadges.length > 0 || trustTier) && (
<div className="mb-5">
<div className="flex items-center justify-between gap-2 mb-3">
<p className="text-xs uppercase tracking-widest text-gray-500 font-semibold">Trust</p>
{trustTier && (
<span className="text-xs px-2 py-0.5 rounded-full font-semibold bg-emerald-700/30 text-emerald-300 capitalize">
{trustTier} tier
</span>
)}
</div>
{trustBadges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{trustBadges.map((b) => (
<span
key={b.id}
title={b.description}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full bg-emerald-900/20 ring-1 ring-emerald-500/40 text-emerald-300 font-medium"
>
<span aria-hidden="true">{b.icon}</span>
{b.label}
</span>
))}
</div>
) : (
<p className="text-sm text-gray-500">
No trust badges yet — verify your email and wallet to build trust.
</p>
)}
</div>
)}

{earned.length === 0 && (
<p className="text-sm text-gray-500 mb-4">
No badges earned yet — keep creating and completing invoices!
Expand Down
228 changes: 228 additions & 0 deletions src/components/CreatorReputationProfile.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="creator-reputation-gauge" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<svg width="128" height="128" viewBox="0 0 128 128" role="img" aria-label={`Reputation score ${clamped} out of 100`}>
<circle cx="64" cy="64" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="12" />
<circle
cx="64"
cy="64"
r={radius}
fill="none"
stroke={color}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform="rotate(-90 64 64)"
/>
<text x="64" y="60" textAnchor="middle" fontSize="28" fontWeight="700" fill="#111827">
{Math.round(clamped)}
</text>
<text x="64" y="82" textAnchor="middle" fontSize="12" fill="#6b7280">
/ 100
</text>
</svg>
<div className="creator-reputation-grade">
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', color: '#6b7280' }}>Grade</div>
<div style={{ fontSize: '2rem', fontWeight: 700, color }}>{grade ?? '—'}</div>
</div>
</div>
);
};

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 (
<div className="creator-reputation-breakdown-row" style={{ marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.875rem', color: '#374151' }}>{label}</span>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#111827' }}>{formatPercent(value)}</span>
</div>
<div style={{ height: '8px', background: '#e5e7eb', borderRadius: '4px', overflow: 'hidden' }}>
<div
style={{
height: '100%',
width: `${normalized !== undefined ? Math.max(0, Math.min(100, normalized)) : 0}%`,
background: barColor,
borderRadius: '4px',
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
);
};

const CreatorReputationProfile: React.FC<CreatorReputationProfileProps> = ({ 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 (
<div className={`creator-reputation-profile ${className ?? ''}`} aria-busy="true">
<div className="creator-reputation-loading" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7280' }}>
Loading reputation…
</div>
</div>
);
}

if (error) {
return (
<div className={`creator-reputation-profile ${className ?? ''}`} role="alert">
<div className="creator-reputation-error" style={{ padding: '1.5rem', color: '#dc2626' }}>
Unable to load reputation for this creator.
</div>
</div>
);
}

const isUnrated =
!reputation ||
reputation.rated === false ||
reputation.score === undefined ||
reputation.score === null;

if (isUnrated) {
return (
<div className={`creator-reputation-profile ${className ?? ''}`}>
<div className="creator-reputation-unrated" style={{ padding: '1.5rem', textAlign: 'center' }}>
<h3 style={{ margin: 0, fontSize: '1.125rem', color: '#111827' }}>Not yet rated</h3>
<p style={{ margin: '0.5rem 0 0', color: '#6b7280', fontSize: '0.875rem' }}>
This creator has not accumulated enough activity to establish a reputation score.
</p>
<div style={{ marginTop: '0.75rem', fontSize: '0.75rem', color: '#9ca3af', wordBreak: 'break-all' }}>
{address}
</div>
</div>
</div>
);
}

const breakdown = reputation.breakdown ?? {};

return (
<div className={`creator-reputation-profile ${className ?? ''}`}>
<div className="creator-reputation-header" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: '#111827' }}>Creator Reputation</h2>
<div style={{ fontSize: '0.75rem', color: '#9ca3af', wordBreak: 'break-all' }}>{address}</div>
</div>

<div className="creator-reputation-score" style={{ marginBottom: '1.5rem' }}>
<ScoreGauge score={reputation.score ?? 0} grade={reputation.grade} />
</div>

<div className="creator-reputation-trust" style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', color: '#6b7280', marginBottom: '0.25rem' }}>
Trust Tier
</div>
<div style={{ fontSize: '1rem', fontWeight: 600, color: '#111827', marginBottom: '0.75rem' }}>
{reputation.trustTier ?? 'Unranked'}
</div>
<CreatorBadges badges={reputation.badges ?? []} />
</div>

<div className="creator-reputation-breakdown" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem', color: '#111827' }}>Breakdown</h3>
<BreakdownRow label="On-time payment rate" value={breakdown.onTimeRate} />
<BreakdownRow label="Completion rate" value={breakdown.completionRate} />
<BreakdownRow label="Dispute rate" value={breakdown.disputeRate} invert />
<BreakdownRow label="Responsiveness" value={breakdown.responsiveness} />
</div>

<div className="creator-reputation-actions">
<button
type="button"
className="creator-reputation-appeal-button"
onClick={() => setIsAppealOpen(true)}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid #d1d5db',
background: '#ffffff',
color: '#374151',
cursor: 'pointer',
fontSize: '0.875rem',
}}
>
Dispute this score
</button>
</div>

{isAppealOpen && (
<ReputationAppealForm
address={address}
currentScore={reputation.score}
onClose={() => setIsAppealOpen(false)}
/>
)}
</div>
);
};

export default CreatorReputationProfile;
Loading
Loading