kyd-shared-badge 0.3.16 → 0.3.18
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.
- package/package.json +1 -1
- package/src/SharedBadgeDisplay.tsx +6 -0
- package/src/colors.ts +34 -1
- package/src/components/GaugeCard.tsx +5 -22
- package/src/components/ReportHeader.tsx +32 -3
- package/src/components/RiskCard.tsx +7 -24
- package/src/types.ts +10 -1
- package/src/utils/date.ts +0 -68
package/package.json
CHANGED
|
@@ -155,6 +155,12 @@ const SharedBadgeDisplay = ({ badgeData, chatProps }: { badgeData: PublicBadgeDa
|
|
|
155
155
|
isPublic={true}
|
|
156
156
|
badgeImageUrl={badgeData.badgeImageUrl || ''}
|
|
157
157
|
summary={report_summary}
|
|
158
|
+
enterpriseMatch={(() => {
|
|
159
|
+
const em = assessmentResult?.enterprise_match;
|
|
160
|
+
if (!em) return null;
|
|
161
|
+
const role = em.role || {};
|
|
162
|
+
return { score: typeof em.score === 'number' ? em.score : undefined, description: em.description, roleName: role.name };
|
|
163
|
+
})()}
|
|
158
164
|
countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
|
|
159
165
|
/>
|
|
160
166
|
</Reveal>
|
package/src/colors.ts
CHANGED
|
@@ -64,4 +64,37 @@ const red3 = '#F5B7B3'
|
|
|
64
64
|
const red4 = '#FADBDA'
|
|
65
65
|
const red5 = '#FDF0EF'
|
|
66
66
|
|
|
67
|
-
export { red1, red2, red3, red4, red5 }
|
|
67
|
+
export { red1, red2, red3, red4, red5 }
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
export function clampPercent(value?: number): number {
|
|
72
|
+
const n = Math.round(Number(value ?? 0))
|
|
73
|
+
if (Number.isNaN(n)) return 0
|
|
74
|
+
return Math.max(0, Math.min(100, n))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function scoreToColorHex(score: number): string {
|
|
78
|
+
const pct = clampPercent(score)
|
|
79
|
+
if (pct <= 33) return red
|
|
80
|
+
if (pct <= 66) return yellow
|
|
81
|
+
return green
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function scoreToCssVar(score: number): string {
|
|
85
|
+
const pct = clampPercent(score)
|
|
86
|
+
if (pct <= 33) return `var(--status-negative, ${red})`
|
|
87
|
+
if (pct <= 66) return `var(--status-neutral, ${yellow})`
|
|
88
|
+
return `var(--status-positive, ${green})`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function hexToRgba(hex: string, alpha: number): string {
|
|
92
|
+
const clean = hex.replace('#', '')
|
|
93
|
+
const r = parseInt(clean.substring(0, 2), 16)
|
|
94
|
+
const g = parseInt(clean.substring(2, 4), 16)
|
|
95
|
+
const b = parseInt(clean.substring(4, 6), 16)
|
|
96
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
@@ -3,23 +3,11 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import BusinessRuleLink from './BusinessRuleLink';
|
|
5
5
|
import { FiInfo } from 'react-icons/fi';
|
|
6
|
-
import {
|
|
6
|
+
import { hexToRgba, scoreToColorHex, scoreToCssVar, clampPercent } from '../colors';
|
|
7
7
|
|
|
8
8
|
type TopMover = { label?: string; uid?: string };
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
const clean = hex.replace('#', '');
|
|
12
|
-
const r = parseInt(clean.substring(0, 2), 16);
|
|
13
|
-
const g = parseInt(clean.substring(2, 4), 16);
|
|
14
|
-
const b = parseInt(clean.substring(4, 6), 16);
|
|
15
|
-
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const pickTint = (score: number) => {
|
|
19
|
-
if (score >= 75) return green;
|
|
20
|
-
if (score >= 50) return yellow;
|
|
21
|
-
return red;
|
|
22
|
-
};
|
|
10
|
+
// color helpers moved to ../utils/color
|
|
23
11
|
|
|
24
12
|
export default function GaugeCard({
|
|
25
13
|
title,
|
|
@@ -38,7 +26,7 @@ export default function GaugeCard({
|
|
|
38
26
|
topMoversTitle?: string;
|
|
39
27
|
tooltipText?: string;
|
|
40
28
|
}) {
|
|
41
|
-
const pct =
|
|
29
|
+
const pct = clampPercent(percent);
|
|
42
30
|
const displayLabel = label || '';
|
|
43
31
|
// Use a fixed internal coordinate system and scale the SVG to container for responsiveness
|
|
44
32
|
const size = 280;
|
|
@@ -47,13 +35,8 @@ export default function GaugeCard({
|
|
|
47
35
|
const circumference = Math.PI * radius;
|
|
48
36
|
const progress = pct / 100;
|
|
49
37
|
const dash = circumference * progress;
|
|
50
|
-
const progressColor =
|
|
51
|
-
|
|
52
|
-
? `var(--status-negative, ${red})`
|
|
53
|
-
: pct <= 66
|
|
54
|
-
? `var(--status-neutral, ${yellow})`
|
|
55
|
-
: `var(--status-positive, ${green})`;
|
|
56
|
-
const headerTint = hexToRgba(pickTint(pct), 0.06);
|
|
38
|
+
const progressColor = scoreToCssVar(pct);
|
|
39
|
+
const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
|
|
57
40
|
|
|
58
41
|
return (
|
|
59
42
|
<div
|
|
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
|
|
4
4
|
import { formatLocalDate } from '../utils/date';
|
|
5
5
|
import countriesLib from 'i18n-iso-countries';
|
|
6
6
|
import enLocale from 'i18n-iso-countries/langs/en.json';
|
|
7
|
+
import { FiInfo } from 'react-icons/fi';
|
|
7
8
|
|
|
8
9
|
// Register English locale once at module import time
|
|
9
10
|
countriesLib.registerLocale(enLocale);
|
|
@@ -36,13 +37,16 @@ interface ReportHeaderProps {
|
|
|
36
37
|
isPublic: boolean;
|
|
37
38
|
badgeImageUrl: string;
|
|
38
39
|
summary?: string;
|
|
40
|
+
enterpriseMatch?: { score?: number; description?: string; roleName?: string } | null;
|
|
39
41
|
countries?: string[];
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImageUrl, summary, countries = [] }: ReportHeaderProps) => {
|
|
44
|
+
const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImageUrl, summary, enterpriseMatch, countries = [] }: ReportHeaderProps) => {
|
|
43
45
|
// Use the dynamic image if available, otherwise fall back to the score-based one.
|
|
44
46
|
const finalBadgeImageUrl = badgeImageUrl || getBadgeImageUrl(score || 0);
|
|
45
47
|
const tint = hexToRgba(pickTint(score || 0), 0.06);
|
|
48
|
+
const matchScore = typeof enterpriseMatch?.score === 'number' ? enterpriseMatch.score : undefined;
|
|
49
|
+
const matchTint = matchScore !== undefined ? hexToRgba(pickTint(matchScore), 0.06) : undefined;
|
|
46
50
|
|
|
47
51
|
const formattedDate = updatedAt ? formatLocalDate(updatedAt, {
|
|
48
52
|
year: 'numeric',
|
|
@@ -59,7 +63,7 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
59
63
|
{/* Left Half: Badge Image with robust centered overlay */}
|
|
60
64
|
<div className="w-full md:w-1/3 flex items-center justify-center self-stretch">
|
|
61
65
|
<div className="relative w-full max-w-xs select-none">
|
|
62
|
-
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={400} height={400}
|
|
66
|
+
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={400} height={400} priority className='w-full h-auto pointer-events-none p-10'/>
|
|
63
67
|
{/* Centered overlay slightly lower on Y axis, responsive and readable */}
|
|
64
68
|
<div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
|
|
65
69
|
<div className="font-extrabold text-black text-3xl " >
|
|
@@ -96,7 +100,32 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
96
100
|
})()
|
|
97
101
|
)}
|
|
98
102
|
</div>
|
|
99
|
-
{
|
|
103
|
+
{(enterpriseMatch?.description || enterpriseMatch?.score !== undefined) ? (
|
|
104
|
+
<div className={'hidden md:block text-sm space-y-2 pt-4'} style={{ borderTop: '1px solid var(--icon-button-secondary)' }}>
|
|
105
|
+
<div className="flex items-center justify-between">
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<p className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Role Match{enterpriseMatch?.roleName ? ` — ${enterpriseMatch.roleName}` : ''}</p>
|
|
108
|
+
<span className={'relative inline-flex items-center group'} style={{ color: 'var(--text-secondary)' }}>
|
|
109
|
+
<FiInfo size={16} />
|
|
110
|
+
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
|
|
111
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
112
|
+
<div style={{ fontWeight: 600 }}>AI-generated</div>
|
|
113
|
+
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>Role match score and description are AI-generated.</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
{typeof enterpriseMatch?.score === 'number' && (
|
|
119
|
+
<span className="px-2 py-1 rounded text-xs font-semibold" style={{ backgroundColor: 'var(--content-card-background)', backgroundImage: matchTint ? `linear-gradient(${matchTint}, ${matchTint})` : undefined, border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)' }}>
|
|
120
|
+
{Math.round(enterpriseMatch.score)}%
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
{enterpriseMatch?.description && (
|
|
125
|
+
<p className={'text-sm'} style={{ color: 'var(--text-main)' }}>{enterpriseMatch.description}</p>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
) : summary && (
|
|
100
129
|
<div className={'hidden md:block text-sm space-y-2 pt-4'} style={{ borderTop: '1px solid var(--icon-button-secondary)' }}>
|
|
101
130
|
<div>
|
|
102
131
|
<p className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Summary:</p>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import BusinessRuleLink from './BusinessRuleLink';
|
|
5
5
|
import { FiInfo } from 'react-icons/fi';
|
|
6
|
-
import {
|
|
6
|
+
import { clampPercent, hexToRgba, scoreToColorHex } from '../colors';
|
|
7
7
|
|
|
8
8
|
type TopMover = { label?: string; uid?: string };
|
|
9
9
|
|
|
@@ -24,32 +24,15 @@ export default function RiskCard({
|
|
|
24
24
|
topMoversTitle?: string;
|
|
25
25
|
tooltipText?: string;
|
|
26
26
|
}) {
|
|
27
|
-
const pctGood =
|
|
27
|
+
const pctGood = clampPercent(percentGood);
|
|
28
28
|
const displayLabel = label || '';
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
const clean = hex.replace('#', '');
|
|
32
|
-
const r = parseInt(clean.substring(0, 2), 16);
|
|
33
|
-
const g = parseInt(clean.substring(2, 4), 16);
|
|
34
|
-
const b = parseInt(clean.substring(4, 6), 16);
|
|
35
|
-
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
36
|
-
};
|
|
30
|
+
// Background tint uses same thresholds as GaugeCard via scoreToColorHex
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (score >= 50) return yellow;
|
|
41
|
-
return red;
|
|
42
|
-
};
|
|
32
|
+
// Foreground bar color uses same thresholds as GaugeCard based on overall score
|
|
33
|
+
const activeColor = scoreToColorHex(pctGood);
|
|
43
34
|
|
|
44
|
-
|
|
45
|
-
// indices 4 and 3 (highest bars) => red, index 2 => yellow, indices 1 and 0 => green
|
|
46
|
-
const colorForIndex = (index: number) => {
|
|
47
|
-
if (index >= 3) return red;
|
|
48
|
-
if (index === 2) return yellow;
|
|
49
|
-
return green;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const headerTint = hexToRgba(pickTint(pctGood), 0.06);
|
|
35
|
+
const headerTint = hexToRgba(scoreToColorHex(pctGood), 0.06);
|
|
53
36
|
|
|
54
37
|
// bar heights ascending representation
|
|
55
38
|
const bars = [40, 60, 85, 110, 140];
|
|
@@ -101,7 +84,7 @@ export default function RiskCard({
|
|
|
101
84
|
style={{
|
|
102
85
|
width: 36,
|
|
103
86
|
height: h,
|
|
104
|
-
backgroundColor: i === activeIndex ?
|
|
87
|
+
backgroundColor: i === activeIndex ? activeColor : 'var(--icon-button-secondary)',
|
|
105
88
|
borderRadius: 4,
|
|
106
89
|
}}
|
|
107
90
|
/>
|
package/src/types.ts
CHANGED
|
@@ -154,6 +154,15 @@ export interface FBIWantedMatch {
|
|
|
154
154
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
155
155
|
raw_result: any;
|
|
156
156
|
}
|
|
157
|
+
export interface EnterpriseMatch {
|
|
158
|
+
role: {
|
|
159
|
+
name: string;
|
|
160
|
+
description: string;
|
|
161
|
+
};
|
|
162
|
+
score: number;
|
|
163
|
+
description: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
157
166
|
|
|
158
167
|
export interface AssessmentResult {
|
|
159
168
|
final_percent: number;
|
|
@@ -225,8 +234,8 @@ export interface AssessmentResult {
|
|
|
225
234
|
skills_matrix?: SkillsMatrix;
|
|
226
235
|
skills_all?: SkillsAll;
|
|
227
236
|
graph_insights?: GraphInsightsPayload;
|
|
237
|
+
enterprise_match?: EnterpriseMatch;
|
|
228
238
|
}
|
|
229
|
-
|
|
230
239
|
export interface ClientMetadata {
|
|
231
240
|
ipAddress: string;
|
|
232
241
|
userAgent: string;
|
package/src/utils/date.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
export type DateLike = string | number | Date | null | undefined;
|
|
2
|
-
|
|
3
|
-
const hasTimezoneDesignator = (value: string): boolean => {
|
|
4
|
-
return /[zZ]$/.test(value) || /[+-]\d{2}:?\d{2}$/.test(value);
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
const truncateExcessFraction = (value: string): string => {
|
|
8
|
-
// Keep at most 3 fractional digits for milliseconds
|
|
9
|
-
return value.replace(/(\.\d{3})\d+/, '$1');
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export const parseUtcDate = (input: DateLike): Date => {
|
|
13
|
-
if (input == null) return new Date(Number.NaN);
|
|
14
|
-
if (input instanceof Date) return new Date(input.getTime());
|
|
15
|
-
if (typeof input === 'number') {
|
|
16
|
-
const millis = input < 1e12 ? input * 1000 : input;
|
|
17
|
-
return new Date(millis);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const raw = String(input).trim();
|
|
21
|
-
if (!raw) return new Date(Number.NaN);
|
|
22
|
-
|
|
23
|
-
// Numeric strings (epoch seconds or millis)
|
|
24
|
-
if (/^\d{10}$/.test(raw)) return new Date(Number(raw) * 1000);
|
|
25
|
-
if (/^\d{13}$/.test(raw)) return new Date(Number(raw));
|
|
26
|
-
|
|
27
|
-
// Already has timezone; trust the browser parser
|
|
28
|
-
if (hasTimezoneDesignator(raw)) return new Date(raw);
|
|
29
|
-
|
|
30
|
-
// Normalize common forms: "YYYY-MM-DD HH:MM:SS[.fff...]" -> ISO-like
|
|
31
|
-
let norm = raw.replace(' ', 'T');
|
|
32
|
-
norm = truncateExcessFraction(norm);
|
|
33
|
-
|
|
34
|
-
// Date-only string -> treat as UTC midnight
|
|
35
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(norm)) {
|
|
36
|
-
return new Date(`${norm}T00:00:00Z`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// If ISO-like without timezone, explicitly mark as UTC
|
|
40
|
-
if (/^\d{4}-\d{2}-\d{2}T/.test(norm) && !hasTimezoneDesignator(norm)) {
|
|
41
|
-
return new Date(`${norm}Z`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Fallback to native parsing
|
|
45
|
-
return new Date(norm);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export const formatLocalDate = (value: DateLike, options?: Intl.DateTimeFormatOptions): string => {
|
|
49
|
-
const d = value instanceof Date ? value : parseUtcDate(value);
|
|
50
|
-
if (Number.isNaN(d.getTime())) return 'N/A';
|
|
51
|
-
return d.toLocaleDateString(undefined, options);
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export const formatLocalDateTime = (value: DateLike, options?: Intl.DateTimeFormatOptions): string => {
|
|
55
|
-
const d = value instanceof Date ? value : parseUtcDate(value);
|
|
56
|
-
if (Number.isNaN(d.getTime())) return 'N/A';
|
|
57
|
-
const base: Intl.DateTimeFormatOptions = {
|
|
58
|
-
year: 'numeric',
|
|
59
|
-
month: 'long',
|
|
60
|
-
day: 'numeric',
|
|
61
|
-
hour: 'numeric',
|
|
62
|
-
minute: '2-digit',
|
|
63
|
-
timeZoneName: 'short',
|
|
64
|
-
};
|
|
65
|
-
return d.toLocaleString(undefined, { ...base, ...(options || {}) });
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
|