forked from Talenttrust/Talenttrust-Frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathReputationProfile.tsx
More file actions
243 lines (228 loc) · 10 KB
/
Copy pathReputationProfile.tsx
File metadata and controls
243 lines (228 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
export type ReputationEvent = {
id: string;
type: string;
summary: string;
date: string;
};
export type ReputationProfileProps = {
name: string;
score?: number | null;
level?: string;
history?: ReputationEvent[];
/** Maximum possible score value. Used for aria-valuemax on the meter role. */
maxScore?: number;
};
export type ReputationBand = {
min: number;
max: number;
label: string;
};
const BASELINE_BANDS = [
{ min: 0, max: 1, label: 'Newcomer' },
{ min: 1, max: 2, label: 'Contributor' },
{ min: 2, max: 3, label: 'Active Contributor' },
{ min: 3, max: 4, label: 'Trusted Partner' },
{ min: 4, max: 5, label: 'Expert' },
];
export function getReputationBands(maxScore: number): ReputationBand[] {
const scale = maxScore / 5;
return BASELINE_BANDS.map((band) => ({
min: band.min * scale,
max: band.max * scale,
label: band.label,
}));
}
export function resolveReputationLevel(score: number, maxScore: number): string {
const bands = getReputationBands(maxScore);
if (score < 0) return bands[0].label;
if (score >= maxScore) return bands[bands.length - 1].label;
const band = bands.find((b, idx) => {
if (idx === bands.length - 1) {
return score >= b.min && score <= b.max;
}
return score >= b.min && score < b.max;
});
return band ? band.label : bands[0].label;
}
const reputationSummary =
'Reputation represents verified trust signals and activity history, not sensitive personal metadata. Privacy-friendly defaults keep your profile safe.';
export default function ReputationProfile({
name,
score,
level,
history = [],
maxScore = 5,
}: ReputationProfileProps) {
const hasReputation = typeof score === 'number' && score >= 0;
const showPartial = hasReputation && history.length === 0;
const resolvedLevel = level !== undefined
? level
: (hasReputation ? resolveReputationLevel(score, maxScore) : 'Community Member');
return (
<section className="w-full max-w-5xl mx-auto space-y-8 px-4 py-10 sm:px-6 lg:px-8" aria-labelledby="profile-heading">
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<h2 className="sr-only" id="profile-heading">Reputation profile for {name}</h2>
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-2xl font-semibold text-white">
{name.slice(0, 1).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-slate-500">Reputation profile</p>
<h1 className="text-2xl font-semibold text-slate-950">{name}</h1>
</div>
</div>
<div className="flex flex-col gap-2 rounded-3xl bg-slate-50 p-4 text-slate-700 sm:p-5">
<p className="text-sm font-medium text-slate-500">Privacy-friendly defaults</p>
<p className="text-sm leading-6">Only summary trust signals are shown by default. Sensitive metadata remains hidden.</p>
</div>
</div>
{/**
* Reputation score meter with accessible semantics.
*
* The score is rendered within a span with role="meter" to expose
* the measured value to assistive technologies. The meter includes
* aria-valuenow, aria-valuemin (0), and aria-valuemax (configurable
* maxScore, defaulting to 5) so screen readers understand the score
* as a quantified range value rather than plain text.
*
* When score is absent or null, the "No reputation yet" text is shown
* without a meter role.
*/}
<div className="mt-8 grid gap-4 sm:grid-cols-3">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<p className="text-sm font-medium text-slate-500" id="reputation-score-label">Reputation score</p>
<p className="mt-3 text-3xl font-semibold text-slate-950" aria-labelledby="reputation-score-label">
{hasReputation ? (
<>
<span
role="meter"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={maxScore}
aria-labelledby="reputation-score-label"
aria-describedby="reputation-legend"
>
<span className="sr-only">Reputation score </span>{score}<span className="sr-only"> out of {maxScore}</span>
</span>
</>
) : 'No reputation yet'}
</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<p className="text-sm font-medium text-slate-500" id="reputation-level-label">Level</p>
<p className="mt-3 text-xl font-semibold text-slate-950" aria-labelledby="reputation-level-label">
{hasReputation ? (
<>
<span className="sr-only">Level </span>{resolvedLevel}
</>
) : 'Pending'}
</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<p className="text-sm font-medium text-slate-500">Explanation</p>
<p className="mt-3 text-sm leading-6 text-slate-700">{reputationSummary}</p>
</div>
</div>
{hasReputation && (
<div className="mt-6 border-t border-slate-200 pt-6">
<h2 className="text-sm font-semibold text-slate-900" id="reputation-legend-title">
Reputation Level Legend
</h2>
<ul
id="reputation-legend"
aria-labelledby="reputation-legend-title"
className="mt-3 grid gap-3 sm:grid-cols-5 text-sm"
>
{getReputationBands(maxScore).map((band) => {
const isActive = score >= band.min && (
band.max === maxScore ? score <= band.max : score < band.max
);
return (
<li
key={band.label}
className={`rounded-2xl border p-3 transition-colors ${
isActive
? 'border-indigo-200 bg-indigo-50/50 text-indigo-900 font-semibold'
: 'border-slate-200 bg-slate-50/50 text-slate-600'
}`}
>
<p className="font-bold text-xs uppercase tracking-wider text-slate-400">
{band.min.toFixed(1)} - {band.max.toFixed(1)}
</p>
<p className="mt-1 text-sm">{band.label}</p>
</li>
);
})}
</ul>
</div>
)}
{showPartial && (
<div className="mt-6 rounded-3xl border border-amber-200 bg-amber-50 p-4 text-amber-900">
<p className="font-semibold">Partial reputation data</p>
<p className="mt-1 text-sm leading-6">
A score exists but history is currently hidden until verified actions are available. This keeps your profile safe and private.
</p>
</div>
)}
</div>
{/**
* Reputation history section.
*
* Semantic notes:
* - Uses `<ol>` (ordered list) because reputation history is inherently
* chronological — the order of events is meaningful.
* - Each event date is wrapped in a `<time>` element whose `dateTime`
* attribute carries a machine-readable ISO-8601 value, improving
* assistive-technology support and SEO date parsing.
* - When the date string is not a valid ISO date the `dateTime` attribute
* is omitted, keeping the markup valid.
*/}
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-slate-950">Reputation history</h2>
<p className="mt-1 text-sm text-slate-500">
History is shown as safe, aggregated events with no wallet or personal metadata by default.
</p>
</div>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-600">
{history.length ? 'Visible' : 'Private by default'}
</span>
</div>
{history.length === 0 ? (
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-6 text-slate-700">
<p className="font-semibold text-slate-900">No reputation history available yet.</p>
<p className="mt-2 text-sm leading-6">
Reputation history appears once you complete verified actions. Your profile remains safe and privacy-friendly until then.
</p>
</div>
) : (
<ol className="space-y-4">
{history.map((event) => {
// Determine whether the date string is a parseable ISO date.
// If it is, expose the ISO value via dateTime for machine readability.
const isValidDate = event.date && !Number.isNaN(Date.parse(event.date));
return (
<li key={event.id} className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-slate-500">{event.type}</p>
<p className="mt-1 text-base font-semibold text-slate-950">{event.summary}</p>
</div>
<time
className="text-sm text-slate-500"
{...(isValidDate ? { dateTime: event.date } : {})}
>
{event.date}
</time>
</div>
</li>
);
})}
</ol>
)}
</div>
</section>
);
}