kyd-shared-badge 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ // import { red, yellow, green } from '@/components/badge/colors';
5
+ import { GraphInsightsPayload, ScoringSummary } from '../types';
6
+ import {
7
+ ResponsiveContainer,
8
+ Tooltip,
9
+ PieChart,
10
+ Pie,
11
+ Cell,
12
+ RadarChart,
13
+ Radar,
14
+ PolarGrid,
15
+ PolarAngleAxis,
16
+ PolarRadiusAxis,
17
+ // BarChart,
18
+ // Bar,
19
+ // XAxis,
20
+ // YAxis,
21
+ // CartesianGrid,
22
+ // Legend,
23
+ } from 'recharts';
24
+
25
+ // avoid platform based charts.
26
+ // target over arching insights that can
27
+ // be used to build a mental model of the user's profile
28
+
29
+ // Manual overrides for the radar chart's average ring per category (0–100).
30
+ // Edit this object to set a custom average for specific categories.
31
+ // Keys must match the category names exactly as they appear on the radar axes.
32
+ // Example:
33
+ const CATEGORY_AVG_OVERRIDES: Record<string, number> = {
34
+ "Authenticity": 87,
35
+ "Activity": 87,
36
+ "Network": 50,
37
+ "Sanctions and Criminal": 100,
38
+ "Telemetry": 100,
39
+ "Portfolio": 65,
40
+ "Skills": 72,
41
+ "Experience": 85,
42
+ };
43
+ // const CATEGORY_AVG_OVERRIDES: Record<string, number> = {};
44
+
45
+ const GraphInsights = ({
46
+ graphInsights,
47
+ categories,
48
+ genre,
49
+ scoringSummary,
50
+ }: {
51
+ graphInsights: GraphInsightsPayload;
52
+ categories?: string[];
53
+ genre: string;
54
+ scoringSummary?: ScoringSummary;
55
+ }) => {
56
+ const getCategoryTooltipCopy = (category: string): string => {
57
+ const name = (category || '').toLowerCase();
58
+
59
+ // Specific technical facets
60
+ if (/network|connection|collab|peer/.test(name)) return 'Signals from the developer’s professional connections, collaborations, and peer recognition.';
61
+ if (/project|repo|portfolio|work/.test(name)) return 'Signals from a developer’s visible projects, repositories, or published work that indicate breadth and quality of output.';
62
+ if (/skill|cert|assessment|endorse/.test(name)) return 'Signals tied to specific technical abilities, such as verified certifications, assessments, or endorsements.';
63
+ if (/experience|tenure|history/.test(name)) return 'Signals of tenure and diversity of professional or project involvement over time.';
64
+ if (/activity|recency|frequency|engage/.test(name)) return 'Signals of recency and frequency of developer engagement in professional or technical platforms.';
65
+
66
+ // Specific risk facets
67
+ if (/sanction|legal|criminal|regulatory|ofac|fbi|watchlist/.test(name)) return 'Signals of legal, criminal, or regulatory red flags linked to an identity.';
68
+ if (/identity|authentic|consisten/.test(name)) return 'Signals that indicate whether a developer’s identity is genuine and consistent across platforms.';
69
+ if (/reputation|review|rating|feedback|perceive|peer/.test(name)) return 'Signals of how peers, clients, and communities perceive the developer.';
70
+ if (/geo|jurisdiction|country|region|ip|location/.test(name)) return 'Signals tied to a developer’s geographic or jurisdictional context.';
71
+ if (/security|cyber/.test(name)) return 'Signals of security posture and potential cyber-risk exposure.';
72
+
73
+ // Generic umbrellas (pick one only)
74
+ if (/risk/.test(name)) return 'KYD Risk surfaces signals of authenticity, reputation, and environmental telemetry that indicate potential risks in engaging with a developer.';
75
+ if (/tech|technical/.test(name)) return 'KYD Technical surfaces signals from a developer’s portfolio, skills, experience, activity, and network to indicate the likelihood of technical capability.';
76
+
77
+ // Fallback
78
+ return 'Share of overall contribution by category based on applied weights.';
79
+ };
80
+
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ const CategoryPieTooltip = ({ active, payload }: any) => {
83
+ if (!active || !payload || !payload.length) return null;
84
+ const p = payload[0];
85
+ const entry = p && p.payload ? p.payload : {};
86
+ const name = entry.name || p.name || '';
87
+ const percent = Math.round(((entry.share || entry.percent || 0) * 100));
88
+ const line = getCategoryTooltipCopy(String(name));
89
+ return (
90
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6, maxWidth: 320 }}>
91
+ <div style={{ fontWeight: 600 }}>{name}</div>
92
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Contributes {percent}% to overall KYD {genre!.charAt(0).toUpperCase() + genre?.slice(1)} Pillar score.</div>
93
+ <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>{line}</div>
94
+ </div>
95
+ );
96
+ };
97
+
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ const RadarCategoryTooltip = ({ active, payload, label }: any) => {
100
+ if (!active || !payload || !payload.length) return null;
101
+ const p = payload[0];
102
+ const entry = p && p.payload ? p.payload : {};
103
+ const name = entry.axis || label || '';
104
+ const score = Number(entry.score ?? p.value ?? 0);
105
+ const avg = entry.avg !== undefined ? Number(entry.avg) : undefined;
106
+ const line = getCategoryTooltipCopy(String(name));
107
+ return (
108
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6, maxWidth: 320 }}>
109
+ <div style={{ fontWeight: 600 }}>{name}</div>
110
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
111
+ Score: {Math.round(score)}{avg !== undefined ? ` • Avg: ${Math.round(avg)}` : ''}
112
+ </div>
113
+ <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>{line}</div>
114
+ </div>
115
+ );
116
+ };
117
+
118
+ const allowedCategoriesSet = (() => {
119
+ const list = Array.isArray(categories) ? categories : undefined;
120
+ if (!list || list.length === 0) return undefined;
121
+ const set = new Set<string>();
122
+ for (const c of list) if (typeof c === 'string' && c) set.add(c);
123
+ return set.size > 0 ? set : undefined;
124
+ })();
125
+
126
+ const categoryData = (graphInsights?.categoryAggregate || [])
127
+ .map((c) => ({ category: c.category, value: Number(c.applied_weight_sum || 0) }))
128
+ .filter((c) => (allowedCategoriesSet ? allowedCategoriesSet.has(c.category) : true))
129
+ .filter((c) => c.value > 0)
130
+ .sort((a, b) => b.value - a.value)
131
+ .slice(0, 10);
132
+
133
+ // Legend dataset for pie: include ALL categories (even 0%) and compute share
134
+ const legendData = (() => {
135
+ const list = (graphInsights?.categoryAggregate || [])
136
+ .map((c) => ({ name: c.category, value: Number(c.applied_weight_sum || 0) }))
137
+ .filter((c) => (allowedCategoriesSet ? allowedCategoriesSet.has(c.name) : true));
138
+ const total = list.reduce((sum, d) => sum + (d.value || 0), 0);
139
+ return list
140
+ .map((d) => ({ ...d, share: total > 0 ? d.value / total : 0 }))
141
+ .sort((a, b) => (b.share - a.share));
142
+ })();
143
+
144
+ // Radar (spider) chart dataset — category scores out of 100 from backend
145
+ const radarData = (() => {
146
+ let percentList = (graphInsights?.categoryScoresPercent || []) as Array<{ category: string; percent?: number }>;
147
+
148
+ // Fallback: derive from scoringSummary.category_scores if graphInsights is missing it
149
+ if ((!percentList || percentList.length === 0) && scoringSummary && scoringSummary.category_scores) {
150
+ percentList = Object.entries(scoringSummary.category_scores).map(([cat, entry]) => ({
151
+ category: cat,
152
+ // Prefer business, else combined, else atomic percent_progress
153
+ percent: Number(
154
+ (entry?.business?.percent_progress ?? entry?.combined?.percent_progress ?? entry?.atomic?.percent_progress ?? 0)
155
+ ),
156
+ }));
157
+ }
158
+
159
+ if (!percentList || percentList.length === 0) return [] as Array<{ axis: string; score: number; avg?: number }>;
160
+
161
+ // Build map for quick lookup
162
+ const percentMap = new Map<string, number>();
163
+ for (const row of percentList) {
164
+ percentMap.set(row.category, Math.max(0, Math.min(100, Math.round(Number(row?.percent || 0)))));
165
+ }
166
+
167
+ // Ensure we include all requested section categories, even if 0
168
+ const axes: string[] = allowedCategoriesSet
169
+ ? Array.from(allowedCategoriesSet)
170
+ : Array.from(new Set(percentList.map((c) => c.category)));
171
+
172
+ const base = axes.map((cat) => ({
173
+ axis: cat,
174
+ score: percentMap.has(cat) ? (percentMap.get(cat) as number) : 0,
175
+ }));
176
+ const values = base.map((d) => d.score);
177
+ const defaultAvg = values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
178
+ const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(Number(n ?? 0))));
179
+ return base
180
+ .map((d) => ({
181
+ ...d,
182
+ avg: Object.prototype.hasOwnProperty.call(CATEGORY_AVG_OVERRIDES, d.axis)
183
+ ? clamp(CATEGORY_AVG_OVERRIDES[d.axis])
184
+ : defaultAvg,
185
+ }))
186
+ .slice(0, 8);
187
+ })();
188
+
189
+ if (!graphInsights) return null;
190
+
191
+ return (
192
+ <div className="grid grid-cols-1 gap-6">
193
+ {/* Spider Chart: Category Balance (genre-scoped) */}
194
+ {radarData && radarData.length > 2 && (
195
+ <div className={'rounded-lg p-4 pb-4 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
196
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6" >
197
+
198
+ {/* Spider Chart: Category Scores */}
199
+ <div className="" style={{ width: '100%', height: 450 }}>
200
+ <div className="" style={{ width: '100%', height: 375 }}>
201
+ <div className="mb-2">
202
+ <div className={'font-medium'} style={{ color: 'var(--text-main)' }}>{genre ? `${genre} ` : ''} Category Contributions - Percentages</div>
203
+ <div className={'text-xs'} style={{ color: 'var(--text-secondary)' }}>The spider diagram displays the KYD {genre} score across its sub-categories, with each point representing the strength of available evidence signals</div>
204
+ </div>
205
+ <ResponsiveContainer>
206
+ <RadarChart data={radarData} cx="50%" cy="50%" outerRadius="70%">
207
+ <PolarGrid stroke="var(--icon-button-secondary)" />
208
+ <PolarAngleAxis dataKey="axis" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} />
209
+ <PolarRadiusAxis angle={55} domain={[0, 100]} tick={{ fill: 'var(--text-secondary)' }} className="text-sm" />
210
+ {/* Primary area */}
211
+ <Radar name="Category Score" dataKey="score" stroke={'var(--text-main)'} fill={'var(--text-main)'} fillOpacity={0.22} />
212
+ {/* Subtle average ring (arbitrary benchmark for comparison) */}
213
+ <Radar name="Avg" dataKey="avg" stroke="rgba(128,128,128,0.6)" fill="rgba(128,128,128,0.08)" strokeDasharray="4 4" fillOpacity={1} />
214
+ <Tooltip content={<RadarCategoryTooltip />} />
215
+ </RadarChart>
216
+ </ResponsiveContainer>
217
+ </div>
218
+ {/* Custom legend below the spider chart */}
219
+ <div className="mt-3 flex items-center justify-center gap-4" style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
220
+ <div className="flex items-center gap-2">
221
+ <span style={{ display: 'inline-block', width: 24, borderBottom: '2px solid var(--text-main)' }} />
222
+ <span>Score</span>
223
+ </div>
224
+ <div className="flex items-center gap-2">
225
+ <span style={{ display: 'inline-block', width: 24, borderBottom: '1px dashed rgba(128,128,128,0.6)' }} />
226
+ <span>Average</span>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ {/* Pie Chart: Category Distribution */}
232
+ <div className="md:border-l md:pl-6" style={{ borderColor: 'var(--icon-button-secondary)', width: '100%', height: 450 }}>
233
+ <div className="" style={{ borderColor: 'var(--icon-button-secondary)', width: '100%', height: 375 }}>
234
+ <div className="mb-2">
235
+ <div className={'font-medium'} style={{ color: 'var(--text-main)' }}>{genre ? `${genre} ` : ''} Category Contributions - Proportions</div>
236
+ <div className={'text-xs'} style={{ color: 'var(--text-secondary)' }}>The donut diagram illustrates the relative contribution of each {genre} category to the pillar’s composite score.</div>
237
+ </div>
238
+ <ResponsiveContainer>
239
+ <PieChart>
240
+ {(() => {
241
+ const isDarkMode = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
242
+ const palette = !isDarkMode
243
+ ? ['#E0E0E0', '#BDBDBD', '#9E9E9E', '#757575', '#616161', '#424242', '#212121']
244
+ : ['#212121', '#424242', '#616161', '#757575', '#9E9E9E', '#BDBDBD', '#E0E0E0'];
245
+ const total = categoryData.reduce((sum, d) => sum + (d.value || 0), 0);
246
+ const pieData = categoryData.map(d => ({
247
+ name: d.category,
248
+ value: d.value,
249
+ share: total > 0 ? (d.value / total) : 0,
250
+ }));
251
+
252
+ // Smaller, theme-aware labels
253
+ const renderLabel = (props: {x: number, y: number, textAnchor: string, percent: number, name: string, payload: {name: string}}) => {
254
+ const pct = Math.round(((props?.percent ?? 0) * 100));
255
+ if (pct < 4) return null;
256
+ const name = props?.name ?? props?.payload?.name;
257
+ const { x, y, textAnchor } = props;
258
+ return (
259
+ <text x={x} y={y} textAnchor={textAnchor} dominantBaseline="central" style={{ fill: 'var(--text-secondary)', fontSize: 12 }}>
260
+ {`${name} ${pct}%`}
261
+ </text>
262
+ );
263
+ };
264
+
265
+ return (
266
+ <Pie
267
+ data={pieData}
268
+ dataKey="value"
269
+ nameKey="name"
270
+ innerRadius={60}
271
+ outerRadius={90}
272
+ startAngle={90}
273
+ endAngle={-270}
274
+ paddingAngle={1.5}
275
+ isAnimationActive
276
+ label={renderLabel}
277
+ labelLine={false}
278
+ >
279
+ {pieData.map((_, idx) => (
280
+ <Cell key={`cat-cell-${idx}`} stroke="var(--content-card-background)" strokeWidth={1} fill={palette[idx % palette.length]} />
281
+ ))}
282
+ </Pie>
283
+ );
284
+ })()}
285
+ <Tooltip content={<CategoryPieTooltip />} />
286
+ </PieChart>
287
+ </ResponsiveContainer>
288
+
289
+ </div>
290
+ {/* Legend below the pie chart: includes 0% categories */}
291
+ <div className="mt-3">
292
+ {(() => {
293
+ const isDarkMode = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
294
+ const palette = !isDarkMode
295
+ ? ['#E0E0E0', '#BDBDBD', '#9E9E9E', '#757575', '#616161', '#424242', '#212121']
296
+ : ['#212121', '#424242', '#616161', '#757575', '#9E9E9E', '#BDBDBD', '#E0E0E0'];
297
+ const pieNames = categoryData.map((d) => d.category);
298
+ const getColor = (name: string) => {
299
+ const idx = pieNames.indexOf(name);
300
+ return idx >= 0 ? palette[idx % palette.length] : 'rgba(128,128,128,0.5)';
301
+ };
302
+ return (
303
+ <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2" style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
304
+ {legendData.map((item, idx) => {
305
+ const pct = Math.round(((item?.share ?? 0) * 100));
306
+ return (
307
+ <div key={`legend-${item.name}-${idx}`} className="flex items-center gap-2">
308
+ <span style={{ display: 'inline-block', width: 10, height: 10, background: getColor(item.name), border: '1px solid var(--icon-button-secondary)' }} />
309
+ <span>{item.name} {pct}%</span>
310
+ </div>
311
+ );
312
+ })}
313
+ </div>
314
+ );
315
+ })()}
316
+ </div>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ )}
321
+
322
+ {/* Provider Impact: Atomic vs Business (stacked) */}
323
+ {/* {providersData && providersData.length > 0 && (
324
+ <div className={'rounded-lg p-4 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
325
+ <h4 className={'font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Provider Impact</h4>
326
+ <p className={'text-sm mb-4'} style={{ color: 'var(--text-secondary)' }}>Which platforms most influence this profile. Stacked bars separate objective signal (Atomic) from business-context signal (Business).</p>
327
+ <div style={{ width: '100%', height: 320 }}>
328
+ <ResponsiveContainer>
329
+ <BarChart data={providersData} margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
330
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--icon-button-secondary)" />
331
+ <XAxis dataKey="name" tick={{ fill: 'var(--text-secondary)' }} />
332
+ <YAxis tick={{ fill: 'var(--text-secondary)' }} />
333
+ <Tooltip
334
+ contentStyle={{ background: 'var(--content-card-background)', border: `1px solid var(--icon-button-secondary)`, color: 'var(--text-main)' }}
335
+ />
336
+ <Legend wrapperStyle={{ color: 'var(--text-secondary)' }} />
337
+ <Bar dataKey="atomic" stackId="a" fill={green} name="Atomic" />
338
+ <Bar dataKey="business" stackId="a" fill={yellow} name="Business" />
339
+ </BarChart>
340
+ </ResponsiveContainer>
341
+ </div>
342
+ </div>
343
+ )} */}
344
+
345
+ </div>
346
+ );
347
+ };
348
+
349
+ export default GraphInsights;
350
+
351
+
@@ -20,7 +20,7 @@ const FindingRow = ({ finding }: { finding: IpRiskAnalysisFinding }) => (
20
20
  );
21
21
 
22
22
  const Section = ({ section }: { section: IpRiskSection }) => (
23
- <div className="py-3">
23
+ <div className="">
24
24
  <h4 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-main)' }}>{section.title}</h4>
25
25
  <ul style={{ listStyleType: 'none', padding: 0, margin: 0 }}>
26
26
  {section.items.map((item, idx) => (
@@ -52,7 +52,7 @@ const IpRiskAnalysisDisplay = ({ ipRiskAnalysis }: IpRiskAnalysisDisplayProps) =
52
52
 
53
53
  if (sections) {
54
54
  return (
55
- <div className="mt-2 divide-y" style={{ borderColor: 'var(--icon-button-secondary)' }}>
55
+ <div className="">
56
56
  <Section section={sections.general} />
57
57
  <Section section={sections.location} />
58
58
  <Section section={sections.reputational} />
@@ -61,8 +61,8 @@ const IpRiskAnalysisDisplay = ({ ipRiskAnalysis }: IpRiskAnalysisDisplayProps) =
61
61
  }
62
62
 
63
63
  return (
64
- <div className="mt-6">
65
- <div className="divide-y" style={{ borderColor: 'var(--icon-button-secondary)' }}>
64
+ <div className="">
65
+ <div>
66
66
  {findings.map((finding, index) => (
67
67
  <FindingRow key={index} finding={finding} />
68
68
  ))}
@@ -1,11 +1,16 @@
1
1
  'use client';
2
2
 
3
3
  import Image from 'next/image';
4
+ import countriesLib from 'i18n-iso-countries';
5
+ import enLocale from 'i18n-iso-countries/langs/en.json';
6
+
7
+ // Register English locale once at module import time
8
+ countriesLib.registerLocale(enLocale);
4
9
 
5
10
  const getBadgeImageUrl = (score: number) => {
6
- if (score >= 75) return '/badgegreen.png';
7
- if (score >= 50) return '/badgeyellow.png';
8
- return '/badgered.png';
11
+ if (score >= 75) return '/badgegreen2.png';
12
+ if (score >= 50) return '/badgeyellow2.png';
13
+ return '/badgered2.png';
9
14
  };
10
15
 
11
16
  const hexToRgba = (hex: string, alpha: number) => {
@@ -29,52 +34,80 @@ interface ReportHeaderProps {
29
34
  score?: number | undefined;
30
35
  isPublic: boolean;
31
36
  badgeImageUrl: string;
37
+ summary?: string;
38
+ countries?: string[];
32
39
  }
33
40
 
34
- const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, isPublic, badgeImageUrl }: ReportHeaderProps) => {
35
- // Use the dynamic image if available, otherwise fall back to the score-based one.
36
- const finalBadgeImageUrl = badgeImageUrl || getBadgeImageUrl(score || 0);
37
- const tint = hexToRgba(pickTint(score || 0), 0.06);
38
-
39
- const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString(undefined, {
40
- year: 'numeric',
41
- month: 'long',
42
- day: 'numeric',
43
- }) : 'N/A';
44
-
45
- return (
46
- <div
47
- className={'mb-8 p-6 rounded-xl shadow-lg flex flex-col md:flex-row items-start md:items-center justify-between gap-6 border'}
48
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', backgroundImage: `linear-gradient(${tint}, ${tint})` }}
49
- >
50
- {/* Left Section */}
51
- <div className="flex items-center text-left md:text-center">
52
- <Image src={finalBadgeImageUrl} alt="KYD Badge" width={100} height={100} unoptimized className='p-3'/>
53
- <div className='flex flex-col items-center justify-center'>
54
- <h1 className={'font-bold text-lg'} style={{ color: 'var(--text-main)' }}>
55
- KYD Self-Check™
56
- </h1>
57
- <p className={'text-sm'} style={{ color: 'var(--text-secondary)' }}>
58
- Private Report
59
- </p>
60
- </div>
61
- </div>
41
+ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImageUrl, summary, countries = [] }: ReportHeaderProps) => {
42
+ // Use the dynamic image if available, otherwise fall back to the score-based one.
43
+ const finalBadgeImageUrl = badgeImageUrl || getBadgeImageUrl(score || 0);
44
+ const tint = hexToRgba(pickTint(score || 0), 0.06);
45
+
46
+ const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString(undefined, {
47
+ year: 'numeric',
48
+ month: 'long',
49
+ day: 'numeric',
50
+ }) : 'N/A';
62
51
 
63
- {/* Middle Section */}
64
- <div className="text-left md:text-center">
65
- <p className={'text-sm'} style={{ color: 'var(--text-secondary)' }}>Developer</p>
66
- <p className={'font-semibold text-2xl'} style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</p>
52
+ return (
53
+ <div
54
+ className={'mb-8 p-6 rounded-xl shadow-lg border'}
55
+ style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', backgroundImage: `linear-gradient(${tint}, ${tint})` }}
56
+ >
57
+ <div className="flex flex-col md:flex-row items-center md:items-stretch gap-6">
58
+ {/* Left Half: Badge Image with robust centered overlay */}
59
+ <div className="w-full md:w-1/3 flex items-center justify-center self-stretch">
60
+ <div className="relative w-full max-w-xs select-none">
61
+ <Image src={finalBadgeImageUrl} alt="KYD Badge" width={400} height={400} unoptimized className='w-full h-auto pointer-events-none p-10'/>
62
+ {/* Centered overlay slightly lower on Y axis, responsive and readable */}
63
+ <div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
64
+ <div className="font-extrabold text-black text-3xl " >
65
+ {Math.round(score || 0)}%
66
+ </div>
67
67
  </div>
68
+ </div>
69
+ </div>
68
70
 
69
- {/* Right Section */}
70
- <div className={'text-left text-sm space-y-1'} style={{ color: 'var(--text-secondary)' }}>
71
- <p><span className={'font-semibold'} style={{ color: 'var(--text-main)' }}>Requested By:</span> {developerName || 'N/A'}</p>
72
- <p><span className={'font-semibold'} style={{ color: 'var(--text-main)' }}>Organization:</span> Unaffiliated</p>
73
- <p><span className={'font-semibold'} style={{ color: 'var(--text-main)' }}>Date Generated:</span> {formattedDate}</p>
74
- <p><span className={'font-semibold'} style={{ color: 'var(--text-main)' }}>Report ID:</span> {badgeId ? `${badgeId.slice(0,4)}...${badgeId.slice(-4)}` : 'N/A'}</p>
71
+ {/* Right Half: Title, Candidate, Details and Summary section */}
72
+ <div className="w-full md:w-2/3">
73
+ <div className={'space-y-4'}>
74
+ <span className='flex gap-2 w-full items-end text-start justify-start'>
75
+ <h2 className={'text-xl font-light'} style={{ color: 'var(--text-main)' }}>KYD Candidate Report:</h2>
76
+ <div className={'text-xl font-bold'} style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</div>
77
+ </span>
78
+ <div className={'text-sm space-y-2'}>
79
+ <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Developer:</span> <span style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</span></p>
80
+ <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Requested By:</span> <span style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</span></p>
81
+ <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Organization:</span> <span style={{ color: 'var(--text-main)' }}>Unaffiliated</span></p>
82
+ <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Date Generated:</span> <span style={{ color: 'var(--text-main)' }}>{formattedDate}</span></p>
83
+ <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Report ID:</span> <span style={{ color: 'var(--text-main)' }}>{badgeId || 'N/A'}</span></p>
84
+ {Array.isArray(countries) && countries.length > 0 && (
85
+ (() => {
86
+ const countryNames = countries
87
+ .map(code => countriesLib.getName((code || '').toUpperCase(), 'en') || code)
88
+ .filter(Boolean);
89
+ return (
90
+ <p>
91
+ <span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Observed Country Affiliations:</span>{' '}
92
+ <span style={{ color: 'var(--text-main)' }}>{countryNames.join(', ')}</span>
93
+ </p>
94
+ );
95
+ })()
96
+ )}
75
97
  </div>
98
+ {summary && (
99
+ <div className={'text-sm space-y-2 pt-4'} style={{ borderTop: '1px solid var(--icon-button-secondary)' }}>
100
+ <div>
101
+ <p className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Summary:</p>
102
+ <p className={'text-sm'} style={{ color: 'var(--text-main)' }}>{summary}</p>
103
+ </div>
104
+ </div>
105
+ )}
106
+ </div>
76
107
  </div>
77
- );
108
+ </div>
109
+ </div>
110
+ );
78
111
  };
79
112
 
80
113
  export default ReportHeader;
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import BusinessRuleLink from './BusinessRuleLink';
5
+ import { FiInfo } from 'react-icons/fi';
6
+
7
+ type TopMover = { label?: string; uid?: string };
8
+
9
+ export default function RiskCard({
10
+ title,
11
+ description,
12
+ percentGood = 0,
13
+ label,
14
+ topMovers,
15
+ topMoversTitle,
16
+ tooltipText,
17
+ }: {
18
+ title: string;
19
+ description?: string;
20
+ percentGood?: number;
21
+ label?: string;
22
+ topMovers?: TopMover[];
23
+ topMoversTitle?: string;
24
+ tooltipText?: string;
25
+ }) {
26
+ const pctGood = Math.max(0, Math.min(100, Math.round(Number(percentGood ?? 0))));
27
+ const displayLabel = label || '';
28
+
29
+ // bar heights descending representation
30
+ const bars = [140, 110, 85, 60, 40];
31
+ let activeIndex = 0; // Default to the tallest bar (highest risk)
32
+ if (pctGood >= 80) {
33
+ activeIndex = 4;
34
+ } else if (pctGood >= 60) {
35
+ activeIndex = 3;
36
+ } else if (pctGood >= 40) {
37
+ activeIndex = 2;
38
+ } else if (pctGood >= 20) {
39
+ activeIndex = 1;
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className={'rounded-md p-5 border flex flex-col'}
45
+ style={{
46
+ backgroundColor: 'var(--content-card-background)',
47
+ borderColor: 'var(--icon-button-secondary)',
48
+ }}
49
+ >
50
+ <div className="mb-3 flex items-start justify-between gap-2">
51
+ <div>
52
+ <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
53
+ {description ? (
54
+ <div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>{description}</div>
55
+ ) : null}
56
+ </div>
57
+ {(tooltipText || description) && (
58
+ <span className={'relative inline-flex items-center group cursor-help'} style={{ color: 'var(--text-secondary)' }}>
59
+ <FiInfo />
60
+ <div className="hidden group-hover:block absolute z-30 right-0 top-full mt-2 w-80">
61
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
62
+ <div style={{ fontWeight: 600 }}>{title}</div>
63
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {pctGood}% (higher is better)</div>
64
+ <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>{tooltipText || description}</div>
65
+ </div>
66
+ </div>
67
+ </span>
68
+ )}
69
+ </div>
70
+ <div className="flex flex-col items-center justify-center gap-1" style={{ minHeight: 250 }}>
71
+ <div className="relative group flex items-end justify-center gap-3">
72
+ {bars.map((h, i) => (
73
+ <div key={i} style={{ width: 36, height: h, backgroundColor: i === activeIndex ? 'var(--text-main)' : 'var(--icon-button-secondary)', borderRadius: 4 }} />
74
+ ))}
75
+ {(tooltipText || description) && (
76
+ <div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
77
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
78
+ <div style={{ fontWeight: 600 }}>{title}</div>
79
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {pctGood}% (higher is better)</div>
80
+ <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>{tooltipText || description}</div>
81
+ </div>
82
+ </div>
83
+ )}
84
+ </div>
85
+ <div className="mt-3 text-center">
86
+ <div className={'text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{displayLabel}</div>
87
+ </div>
88
+ </div>
89
+ {Array.isArray(topMovers) && topMovers.length > 0 && (
90
+ <div className="mt-4 text-center">
91
+ <div className={'text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{topMoversTitle || 'Top Score Movers'}</div>
92
+ <div className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
93
+ {topMovers.map((t, idx: number) => (
94
+ <React.Fragment key={idx}>
95
+ <BusinessRuleLink uid={t?.uid} label={t?.label || ''} />
96
+ {idx < topMovers.length - 1 && <span className="mx-2">|</span>}
97
+ </React.Fragment>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+