Skip to content

Commit f3b40bb

Browse files
Merge pull request #67 from luighis/feat/reputation-badge-component
feat: create badge component
2 parents 39ff035 + 301b17e commit f3b40bb

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

components/reputation-badge.tsx

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"use client";
2+
import { useState } from "react";
3+
import { ShieldCheck, Star, Scale, CheckCircle2, Loader2 } from "lucide-react";
4+
import { cn } from "@/lib/utils";
5+
6+
export type BadgeType =
7+
| "verified-freelancer"
8+
| "top-rated"
9+
| "dispute-free"
10+
| "100-completion";
11+
12+
export type BadgeSize = "sm" | "md" | "lg";
13+
14+
export interface ReputationBadgeProps {
15+
type: BadgeType;
16+
txHash?: string;
17+
explorerUrl?: string;
18+
verify?: (txHash?: string) => Promise<boolean>;
19+
size?: BadgeSize;
20+
showLabel?: boolean;
21+
className?: string;
22+
}
23+
24+
type BadgeMeta = {
25+
label: string;
26+
description: string;
27+
Icon: typeof ShieldCheck;
28+
containerClass: string;
29+
iconClass: string;
30+
ringClass: string;
31+
};
32+
33+
const BADGES: Record<BadgeType, BadgeMeta> = {
34+
"verified-freelancer": {
35+
label: "Verified Freelancer",
36+
description: "Identity verified on-chain via attestation.",
37+
Icon: ShieldCheck,
38+
containerClass:
39+
"bg-[var(--badge-verified-bg)] text-[var(--badge-verified-fg)] border-[var(--badge-verified-border)]",
40+
iconClass: "text-[var(--badge-verified-fg)]",
41+
ringClass: "ring-[var(--badge-verified-fg)]/30",
42+
},
43+
"top-rated": {
44+
label: "Top Rated",
45+
description: "Consistently rated 4.8★ or higher by clients.",
46+
Icon: Star,
47+
containerClass:
48+
"bg-[var(--badge-top-bg)] text-[var(--badge-top-fg)] border-[var(--badge-top-border)]",
49+
iconClass: "text-[var(--badge-top-fg)]",
50+
ringClass: "ring-[var(--badge-top-fg)]/30",
51+
},
52+
"dispute-free": {
53+
label: "Dispute-Free",
54+
description: "No client disputes recorded on-chain.",
55+
Icon: Scale,
56+
containerClass:
57+
"bg-[var(--badge-dispute-bg)] text-[var(--badge-dispute-fg)] border-[var(--badge-dispute-border)]",
58+
iconClass: "text-[var(--badge-dispute-fg)]",
59+
ringClass: "ring-[var(--badge-dispute-fg)]/30",
60+
},
61+
"100-completion": {
62+
label: "100% Completion",
63+
description: "Every contract delivered and accepted on-chain.",
64+
Icon: CheckCircle2,
65+
containerClass:
66+
"bg-[var(--badge-completion-bg)] text-[var(--badge-completion-fg)] border-[var(--badge-completion-border)]",
67+
iconClass: "text-[var(--badge-completion-fg)]",
68+
ringClass: "ring-[var(--badge-completion-fg)]/30",
69+
},
70+
};
71+
72+
const SIZES: Record<
73+
BadgeSize,
74+
{ wrap: string; icon: string; text: string; gap: string }
75+
> = {
76+
sm: {
77+
wrap: "px-2 py-1 rounded-md",
78+
icon: "h-3.5 w-3.5",
79+
text: "text-xs",
80+
gap: "gap-1",
81+
},
82+
md: {
83+
wrap: "px-3 py-1.5 rounded-lg",
84+
icon: "h-4 w-4",
85+
text: "text-sm",
86+
gap: "gap-1.5",
87+
},
88+
lg: {
89+
wrap: "px-4 py-2 rounded-xl",
90+
icon: "h-5 w-5",
91+
text: "text-base",
92+
gap: "gap-2",
93+
},
94+
};
95+
96+
const explorer = "https://etherscan.io/tx/";
97+
const demoHash =
98+
"0x9f2c1a7b4e6d3f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a";
99+
100+
const verifyTest = async (hash?: string) => {
101+
await new Promise((r) => setTimeout(r, 700));
102+
return Boolean(hash);
103+
};
104+
105+
export function ReputationBadge({
106+
type,
107+
txHash = demoHash,
108+
explorerUrl = explorer,
109+
verify = verifyTest,
110+
size = "md",
111+
showLabel = true,
112+
className,
113+
}: ReputationBadgeProps) {
114+
const meta = BADGES[type];
115+
const sizing = SIZES[size];
116+
const { Icon } = meta;
117+
118+
const [status, setStatus] = useState<
119+
"idle" | "verifying" | "verified" | "failed"
120+
>(txHash ? "idle" : "idle");
121+
122+
const handleVerify = async () => {
123+
if (status === "verifying") return;
124+
setStatus("verifying");
125+
try {
126+
const ok = verify ? await verify(txHash) : Boolean(txHash);
127+
setStatus(ok ? "verified" : "failed");
128+
} catch {
129+
setStatus("failed");
130+
}
131+
};
132+
133+
return (
134+
<button
135+
type="button"
136+
onClick={handleVerify}
137+
onKeyDown={(e) => {
138+
if (e.key === "Enter" || e.key === " ") {
139+
e.preventDefault();
140+
handleVerify();
141+
}
142+
}}
143+
title={`${meta.label}${explorerUrl && txHash ? ` — ${explorerUrl}${txHash}` : ""}`}
144+
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-xl"
145+
>
146+
<span
147+
role="img"
148+
aria-label={`${meta.label} badge${status === "verified" ? ", on-chain verified" : ""}`}
149+
className={cn(
150+
"inline-flex items-center border font-medium select-none transition-all",
151+
"ring-1 ring-inset",
152+
sizing.wrap,
153+
sizing.text,
154+
sizing.gap,
155+
meta.containerClass,
156+
meta.ringClass,
157+
className,
158+
)}
159+
>
160+
{status === "verifying" ? (
161+
<Loader2
162+
className={cn(sizing.icon, "animate-spin", meta.iconClass)}
163+
aria-hidden="true"
164+
/>
165+
) : (
166+
<Icon
167+
className={cn(sizing.icon, meta.iconClass)}
168+
aria-hidden="true"
169+
/>
170+
)}
171+
{showLabel && <span>{meta.label}</span>}
172+
{status === "verified" && (
173+
<span
174+
aria-hidden="true"
175+
className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-current"
176+
/>
177+
)}
178+
</span>
179+
</button>
180+
);
181+
}
182+
183+
export interface ReputationBadgeGroupProps {
184+
badges: Array<Omit<ReputationBadgeProps, "size"> & { id?: string }>;
185+
size?: BadgeSize;
186+
className?: string;
187+
}
188+
189+
export function ReputationBadgeGroup({
190+
badges,
191+
size = "md",
192+
className,
193+
}: ReputationBadgeGroupProps) {
194+
return (
195+
<div
196+
role="list"
197+
aria-label="On-chain reputation badges"
198+
className={cn("flex flex-wrap items-center gap-2", className)}
199+
>
200+
{badges.map((b, i) => (
201+
<div role="listitem" key={b.id ?? `${b.type}-${i}`}>
202+
<ReputationBadge {...b} size={size} />
203+
</div>
204+
))}
205+
</div>
206+
);
207+
}
208+
209+
export default ReputationBadge;

0 commit comments

Comments
 (0)