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.
- package/package.json +7 -5
- package/src/SharedBadgeDisplay.tsx +464 -226
- package/src/components/AppendixTables.tsx +105 -11
- package/src/components/BusinessRuleLink.tsx +33 -0
- package/src/components/BusinessRulesContext.tsx +56 -0
- package/src/components/CategoryBars.tsx +100 -0
- package/src/components/ConnectedPlatforms.tsx +67 -0
- package/src/components/GaugeCard.tsx +99 -0
- package/src/components/GraphInsights.tsx +351 -0
- package/src/components/IpRiskAnalysisDisplay.tsx +4 -4
- package/src/components/ReportHeader.tsx +75 -42
- package/src/components/RiskCard.tsx +106 -0
- package/src/components/Skills.tsx +422 -0
- package/src/components/SkillsAppendixTable.tsx +83 -0
- package/src/types.ts +223 -11
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { FaGithub, FaGitlab, FaStackOverflow, FaLinkedin, FaGoogle, FaKaggle } from 'react-icons/fa';
|
|
5
|
+
import { SiCredly, SiFiverr } from 'react-icons/si';
|
|
4
6
|
import { DomainCSVRow } from '../types';
|
|
5
7
|
|
|
6
8
|
interface SanctionSource {
|
|
@@ -19,9 +21,19 @@ interface DomainSource {
|
|
|
19
21
|
|
|
20
22
|
const PAGE_SIZE = 25;
|
|
21
23
|
|
|
24
|
+
interface BusinessRuleRow {
|
|
25
|
+
provider: string;
|
|
26
|
+
category?: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
likert_label?: string;
|
|
29
|
+
likert_value?: number;
|
|
30
|
+
weight?: number;
|
|
31
|
+
uid?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
interface AppendixTableProps {
|
|
23
|
-
type: 'sanctions' | 'domains';
|
|
24
|
-
sources: (string | DomainCSVRow | SanctionSource)[];
|
|
35
|
+
type: 'sanctions' | 'domains' | 'business_rules';
|
|
36
|
+
sources: (string | DomainCSVRow | SanctionSource | BusinessRuleRow)[];
|
|
25
37
|
searchedAt: string;
|
|
26
38
|
developerName: string;
|
|
27
39
|
}
|
|
@@ -117,6 +129,28 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
|
|
|
117
129
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
|
118
130
|
const [expanded, setExpanded] = useState<{ [k: number]: boolean }>({});
|
|
119
131
|
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const flashIfRule = () => {
|
|
134
|
+
const hash = typeof window !== 'undefined' ? window.location.hash : '';
|
|
135
|
+
if (!hash || !hash.startsWith('#rule-')) return;
|
|
136
|
+
const id = hash.slice(1);
|
|
137
|
+
const el = document.getElementById(id) as HTMLElement | null;
|
|
138
|
+
if (!el) return;
|
|
139
|
+
try {
|
|
140
|
+
el.style.scrollMarginTop = '96px';
|
|
141
|
+
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
142
|
+
} catch {}
|
|
143
|
+
el.style.transition = 'background-color 300ms ease';
|
|
144
|
+
el.style.backgroundColor = 'rgba(2, 163, 137, 0.14)';
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
el.style.backgroundColor = '';
|
|
147
|
+
}, 1200);
|
|
148
|
+
};
|
|
149
|
+
flashIfRule();
|
|
150
|
+
window.addEventListener('hashchange', flashIfRule);
|
|
151
|
+
return () => window.removeEventListener('hashchange', flashIfRule);
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
120
154
|
const formattedDate = new Date(searchedAt).toLocaleString(undefined, {
|
|
121
155
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
122
156
|
// hour: 'numeric', minute: '2-digit',
|
|
@@ -124,28 +158,44 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
|
|
|
124
158
|
|
|
125
159
|
const headers = type === 'sanctions'
|
|
126
160
|
? ["Issuing Entity", "List Name", "List Searched On", "Value", "Findings"]
|
|
127
|
-
:
|
|
161
|
+
: type === 'domains'
|
|
162
|
+
? ["Country", "Entity Type", "Entity/Domain", "Searched On", "Value", "Findings"]
|
|
163
|
+
: ["Platform", "Category", "Observation", "Imapct"];
|
|
128
164
|
|
|
129
165
|
const parsedSources = type === 'sanctions'
|
|
130
|
-
? (sources
|
|
166
|
+
? (sources).map((src) => {
|
|
131
167
|
if (typeof src === 'string') {
|
|
132
168
|
const parts = src.split(':');
|
|
133
169
|
return { issuingEntity: parts[0].trim(), listName: parts.slice(1).join(':').trim(), matched: false } as SanctionSource;
|
|
134
170
|
}
|
|
135
171
|
return src as SanctionSource;
|
|
136
172
|
})
|
|
137
|
-
:
|
|
173
|
+
: type === 'domains'
|
|
174
|
+
? (sources as DomainCSVRow[]).map(s => ({
|
|
138
175
|
country: s.Country,
|
|
139
176
|
entityType: s['Entity Type'],
|
|
140
177
|
entityName: s['Entity Name'],
|
|
141
178
|
url: s.URL,
|
|
179
|
+
}))
|
|
180
|
+
: (sources as BusinessRuleRow[]).map(s => ({
|
|
181
|
+
provider: s.provider,
|
|
182
|
+
category: s.category || '',
|
|
183
|
+
label: s.label || '',
|
|
184
|
+
likert_label: s.likert_label || '',
|
|
185
|
+
likert_value: s.likert_value || 0,
|
|
186
|
+
weight: s.weight || 0,
|
|
187
|
+
uid: s.uid || '',
|
|
142
188
|
}));
|
|
143
189
|
|
|
144
190
|
const sortedParsedSources = (type === 'sanctions')
|
|
145
191
|
? (parsedSources as SanctionSource[]).slice().sort((a, b) => (b.matched === true ? 1 : 0) - (a.matched === true ? 1 : 0))
|
|
192
|
+
: type === 'business_rules'
|
|
193
|
+
? (parsedSources as BusinessRuleRow[]).slice().sort((a, b) => (Number(b.weight || 0) - Number(a.weight || 0)))
|
|
146
194
|
: parsedSources;
|
|
147
195
|
|
|
148
|
-
const visibleParsedSources =
|
|
196
|
+
const visibleParsedSources = (type === 'business_rules')
|
|
197
|
+
? sortedParsedSources
|
|
198
|
+
: sortedParsedSources.slice(0, visibleCount);
|
|
149
199
|
|
|
150
200
|
const handleLoadMore = () => {
|
|
151
201
|
setVisibleCount(currentCount => currentCount + PAGE_SIZE);
|
|
@@ -161,7 +211,7 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
|
|
|
161
211
|
<table className={'min-w-full'}>
|
|
162
212
|
<colgroup>
|
|
163
213
|
{headers.map((_, idx) => (
|
|
164
|
-
<col key={idx} style={idx === headers.length - 1 ? { width: '
|
|
214
|
+
<col key={idx} style={type === 'business_rules' ? { width: `${100 / headers.length}%` } : (idx === headers.length - 1 ? { width: '50%' } : {})} />
|
|
165
215
|
))}
|
|
166
216
|
</colgroup>
|
|
167
217
|
<thead className={''}>
|
|
@@ -189,12 +239,45 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
|
|
|
189
239
|
/>
|
|
190
240
|
);
|
|
191
241
|
}
|
|
192
|
-
|
|
242
|
+
if (type === 'domains') {
|
|
243
|
+
return <DomainRow key={index} source={source as DomainSource} searchedAt={formattedDate} developerName={developerName} />
|
|
244
|
+
}
|
|
245
|
+
const br = source as BusinessRuleRow;
|
|
246
|
+
const anchorId = br.uid ? `rule-${br.uid}` : undefined;
|
|
247
|
+
const ProviderIcon = ({ name }: { name?: string }) => {
|
|
248
|
+
const n = (name || '').toLowerCase();
|
|
249
|
+
if (n.includes('github')) return <FaGithub />;
|
|
250
|
+
if (n.includes('gitlab')) return <FaGitlab />;
|
|
251
|
+
if (n.includes('stack')) return <FaStackOverflow />;
|
|
252
|
+
if (n.includes('credly')) return <SiCredly />;
|
|
253
|
+
if (n.includes('fiverr')) return <SiFiverr />;
|
|
254
|
+
if (n.includes('kaggle')) return <FaKaggle />;
|
|
255
|
+
if (n.includes('google')) return <FaGoogle />;
|
|
256
|
+
if (n.includes('linkedin')) return <FaLinkedin />;
|
|
257
|
+
return <span className="inline-block w-8 h-8 rounded-full" style={{ backgroundColor: 'var(--icon-button-secondary)' }} />;
|
|
258
|
+
};
|
|
259
|
+
return (
|
|
260
|
+
<tr id={anchorId} key={index} className={'transition-colors hover:bg-black/5'}>
|
|
261
|
+
<td className={'px-4 py-4 whitespace-normal text-sm w-1/4'} style={{ color: 'var(--text-secondary)' }}>
|
|
262
|
+
<span title={br.provider || ''} className={'inline-flex items-center text-4xl'} style={{ color: 'var(--text-main)' }}>
|
|
263
|
+
<ProviderIcon name={br.provider} />
|
|
264
|
+
</span>
|
|
265
|
+
</td>
|
|
266
|
+
<td className={'px-4 py-4 whitespace-normal text-sm w-1/4'} style={{ color: 'var(--text-secondary)' }}>{br.category || '—'}</td>
|
|
267
|
+
<td className={'px-4 py-4 whitespace-normal text-sm font-medium w-1/4'} style={{ color: 'var(--text-main)' }}>{br.label || '—'}</td>
|
|
268
|
+
<td className={'px-4 py-4 whitespace-normal text-sm w-1/4'} style={{ color: 'var(--text-secondary)' }}>{(() => {
|
|
269
|
+
const weight = br.weight || 0;
|
|
270
|
+
if (weight > 0) return 'Positive';
|
|
271
|
+
if (weight < 0) return 'Negative';
|
|
272
|
+
return '—';
|
|
273
|
+
})()}</td>
|
|
274
|
+
</tr>
|
|
275
|
+
);
|
|
193
276
|
})}
|
|
194
277
|
</tbody>
|
|
195
278
|
</table>
|
|
196
279
|
</div>
|
|
197
|
-
{parsedSources.length > PAGE_SIZE && (
|
|
280
|
+
{type !== 'business_rules' && parsedSources.length > PAGE_SIZE && (
|
|
198
281
|
<div className={'mt-4 flex items-center justify-between text-sm'} style={{ color: 'var(--text-secondary)' }}>
|
|
199
282
|
<p>
|
|
200
283
|
Showing {Math.min(visibleCount, parsedSources.length)} of {parsedSources.length} entries
|
|
@@ -214,4 +297,15 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
|
|
|
214
297
|
);
|
|
215
298
|
};
|
|
216
299
|
|
|
217
|
-
export default AppendixTables;
|
|
300
|
+
export default AppendixTables;
|
|
301
|
+
|
|
302
|
+
// deprecated SharedBadgeDisplay code, storing here for later
|
|
303
|
+
{/* <div>
|
|
304
|
+
<h4 className={'text-xl font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Country-specific Entity Affiliations</h4>
|
|
305
|
+
<AppendixTables
|
|
306
|
+
type="domains"
|
|
307
|
+
sources={screening_sources.risk_profile_domains || []}
|
|
308
|
+
searchedAt={updatedAt}
|
|
309
|
+
developerName={developerName || 'this developer'}
|
|
310
|
+
/>
|
|
311
|
+
</div> */}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useBusinessRuleMeta } from './BusinessRulesContext';
|
|
5
|
+
|
|
6
|
+
const BusinessRuleLink = ({ uid, label, className }: { uid?: string; label: string; className?: string }) => {
|
|
7
|
+
const href = uid ? `#rule-${uid}` : undefined;
|
|
8
|
+
const meta = useBusinessRuleMeta(uid);
|
|
9
|
+
const tooltip = meta && (meta?.category || meta?.likert_label)
|
|
10
|
+
? `${meta.category || ''}${meta.category && meta.likert_label ? ' - ' : ''}${meta.likert_label || ''}`
|
|
11
|
+
: undefined;
|
|
12
|
+
if (!href) return <span className={className} style={{ color: 'var(--text-secondary)' }}>{label}</span>;
|
|
13
|
+
return (
|
|
14
|
+
<span className={'relative inline-flex items-center group'}>
|
|
15
|
+
<a href={href} className={className || 'underline underline-offset-2'} style={{ color: 'var(--text-secondary)' }}>
|
|
16
|
+
{label}
|
|
17
|
+
</a>
|
|
18
|
+
{tooltip && (
|
|
19
|
+
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-72">
|
|
20
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6, fontSize: 12 }}>
|
|
21
|
+
<div style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>
|
|
22
|
+
<span className='font-semibold'>{meta?.category || ''} | </span> <span>{meta?.likert_label || ''}</span>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default BusinessRuleLink;
|
|
32
|
+
|
|
33
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useMemo } from 'react';
|
|
4
|
+
import { GraphInsightsPayload } from '../types';
|
|
5
|
+
|
|
6
|
+
type BusinessRuleMeta = {
|
|
7
|
+
uid?: string;
|
|
8
|
+
category?: string;
|
|
9
|
+
likert_label?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
provider?: string;
|
|
12
|
+
weight?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type BusinessRuleIndex = Record<string, BusinessRuleMeta>;
|
|
16
|
+
|
|
17
|
+
const BusinessRulesContext = createContext<BusinessRuleIndex | null>(null);
|
|
18
|
+
|
|
19
|
+
export const BusinessRulesProvider = ({
|
|
20
|
+
items,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
items?: GraphInsightsPayload['business_rules_all'];
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}) => {
|
|
26
|
+
const index = useMemo<BusinessRuleIndex>(() => {
|
|
27
|
+
const map: BusinessRuleIndex = {};
|
|
28
|
+
for (const it of items || []) {
|
|
29
|
+
try {
|
|
30
|
+
const uid = String(((it as unknown) as { uid?: string })?.uid || '');
|
|
31
|
+
if (!uid) continue;
|
|
32
|
+
map[uid] = {
|
|
33
|
+
uid,
|
|
34
|
+
category: it?.category,
|
|
35
|
+
likert_label: it?.likert_label,
|
|
36
|
+
label: it?.label,
|
|
37
|
+
provider: it?.provider,
|
|
38
|
+
weight: it?.weight,
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore malformed item
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return map;
|
|
45
|
+
}, [items]);
|
|
46
|
+
|
|
47
|
+
return <BusinessRulesContext.Provider value={index}>{children}</BusinessRulesContext.Provider>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const useBusinessRuleMeta = (uid?: string): BusinessRuleMeta | undefined => {
|
|
51
|
+
const ctx = useContext(BusinessRulesContext);
|
|
52
|
+
if (!ctx || !uid) return undefined;
|
|
53
|
+
return ctx[uid];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
type CategoryBarsProps = {
|
|
6
|
+
title: string;
|
|
7
|
+
categories: string[];
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
categoryScores: Record<string, any>;
|
|
10
|
+
barColor: (percent: number) => string;
|
|
11
|
+
getCategoryTooltipCopy: (category: string) => string;
|
|
12
|
+
barHeight?: number; // px height for the filled bar
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const CategoryBars: React.FC<CategoryBarsProps> = ({
|
|
16
|
+
title,
|
|
17
|
+
categories,
|
|
18
|
+
categoryScores,
|
|
19
|
+
getCategoryTooltipCopy,
|
|
20
|
+
barHeight = 6,
|
|
21
|
+
}) => {
|
|
22
|
+
return (
|
|
23
|
+
<div className="relative flex flex-col h-full">
|
|
24
|
+
<div className="font-semibold text-xl mb-2" style={{ color: 'var(--text-main)' }}>{title}</div>
|
|
25
|
+
<div className="flex-1 flex flex-col justify-between relative">
|
|
26
|
+
<div
|
|
27
|
+
className="absolute top-0 bottom-0 w-px"
|
|
28
|
+
style={{
|
|
29
|
+
left: '50%',
|
|
30
|
+
transform: 'translateX(-50%)',
|
|
31
|
+
backgroundColor: 'var(--text-secondary, #e5e7eb)',
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
{categories.map((category) => {
|
|
35
|
+
const score = categoryScores[category] || {};
|
|
36
|
+
const signed = Math.round(
|
|
37
|
+
Number(
|
|
38
|
+
score?.business?.percent_progress_signed ??
|
|
39
|
+
score?.combined?.percent_progress_signed ??
|
|
40
|
+
score?.business?.percent_progress ??
|
|
41
|
+
score?.combined?.percent_progress ??
|
|
42
|
+
0
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
const absPercent = Math.max(0, Math.min(Math.abs(signed), 100));
|
|
46
|
+
const isNegative = signed < 0;
|
|
47
|
+
const label =
|
|
48
|
+
signed <= -61 ? 'Strong Evidence' :
|
|
49
|
+
signed <= -21 ? 'Consistent Evidence' :
|
|
50
|
+
signed <= 19 ? 'Mixed Signals' :
|
|
51
|
+
signed <= 59 ? 'Consistent Evidence' :
|
|
52
|
+
'Strong Evidence';
|
|
53
|
+
const fillWidth = absPercent / 2; // half-bar represents 100%
|
|
54
|
+
const left = isNegative ? `calc(50% - ${fillWidth}%)` : '50%';
|
|
55
|
+
return (
|
|
56
|
+
<div key={category} className="first:pt-0">
|
|
57
|
+
<div className={'font-semibold mb-1'} style={{ color: 'var(--text-main)' }}>
|
|
58
|
+
{category}
|
|
59
|
+
</div>
|
|
60
|
+
<div className="relative group">
|
|
61
|
+
<div
|
|
62
|
+
className="w-full rounded-full overflow-hidden relative"
|
|
63
|
+
style={{
|
|
64
|
+
height: barHeight,
|
|
65
|
+
background: 'transparent',
|
|
66
|
+
outline: '1px solid var(--icon-button-secondary)',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{/* signed fill originating from center */}
|
|
70
|
+
<div className="absolute top-0 h-full" style={{ left, width: `${fillWidth}%`, backgroundColor: 'var(--bubble-foreground)' }} />
|
|
71
|
+
</div>
|
|
72
|
+
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
|
|
73
|
+
<div
|
|
74
|
+
style={{
|
|
75
|
+
background: 'var(--content-card-background)',
|
|
76
|
+
border: '1px solid var(--icon-button-secondary)',
|
|
77
|
+
color: 'var(--text-main)',
|
|
78
|
+
padding: 10,
|
|
79
|
+
borderRadius: 6,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<div style={{ fontWeight: 600 }}>{category}</div>
|
|
83
|
+
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{label}</div>
|
|
84
|
+
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
|
|
85
|
+
{getCategoryTooltipCopy(category)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export default CategoryBars;
|
|
99
|
+
|
|
100
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { FaGithub, FaGitlab, FaStackOverflow, FaLinkedin, FaGoogle, FaKaggle } from 'react-icons/fa';
|
|
5
|
+
import { SiCredly, SiFiverr } from 'react-icons/si';
|
|
6
|
+
|
|
7
|
+
type ConnectedAccount = {
|
|
8
|
+
name?: string;
|
|
9
|
+
url?: string | null;
|
|
10
|
+
handle?: string | null;
|
|
11
|
+
observedAt?: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ProviderIcon = ({ name }: { name?: string }) => {
|
|
15
|
+
const n = (name || '').toLowerCase();
|
|
16
|
+
if (n.includes('github')) return <FaGithub />;
|
|
17
|
+
if (n.includes('gitlab')) return <FaGitlab />;
|
|
18
|
+
if (n.includes('stack')) return <FaStackOverflow />;
|
|
19
|
+
if (n.includes('credly')) return <SiCredly />;
|
|
20
|
+
if (n.includes('fiverr')) return <SiFiverr />;
|
|
21
|
+
if (n.includes('kaggle')) return <FaKaggle />;
|
|
22
|
+
if (n.includes('google')) return <FaGoogle />;
|
|
23
|
+
if (n.includes('linkedin')) return <FaLinkedin />;
|
|
24
|
+
return <span className="inline-block w-4 h-4 rounded-full" style={{ backgroundColor: 'var(--icon-button-secondary)' }} />;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ConnectedPlatforms = ({ accounts }: { accounts?: ConnectedAccount[] }) => {
|
|
28
|
+
const list = Array.isArray(accounts) ? accounts : [];
|
|
29
|
+
if (list.length === 0) return null;
|
|
30
|
+
return (
|
|
31
|
+
<div className="pt-8">
|
|
32
|
+
<h4 className={'text-lg font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Sources (Connected or Linked)</h4>
|
|
33
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
34
|
+
{list.map((acct, idx) => (
|
|
35
|
+
<div key={idx} className={'rounded-md p-3 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
|
|
36
|
+
<div className="flex items-center justify-between gap-3">
|
|
37
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
38
|
+
<div className={'text-lg'} style={{ color: 'var(--text-main)' }}>
|
|
39
|
+
<ProviderIcon name={acct.name} />
|
|
40
|
+
</div>
|
|
41
|
+
<div className={'text-sm font-medium truncate'} style={{ color: 'var(--text-main)' }}>{acct.name}</div>
|
|
42
|
+
</div>
|
|
43
|
+
{acct.observedAt ? (
|
|
44
|
+
<div className={'text-xs whitespace-nowrap'} style={{ color: 'var(--text-secondary)' }}>
|
|
45
|
+
<span style={{ color: 'var(--text-main)' }}>Observed At:</span> {new Date(acct.observedAt).toLocaleDateString()}
|
|
46
|
+
</div>
|
|
47
|
+
) : <div />}
|
|
48
|
+
</div>
|
|
49
|
+
<div className={'text-xs mt-2 truncate'} style={{ color: 'var(--text-secondary)' }}>
|
|
50
|
+
{acct.url ? (
|
|
51
|
+
<a href={acct.url} target="_blank" rel="noopener noreferrer" className={'font-medium underline underline-offset-2'} style={{ color: 'var(--text-secondary)' }}>
|
|
52
|
+
{acct.handle || acct.url}
|
|
53
|
+
</a>
|
|
54
|
+
) : (
|
|
55
|
+
<span className={'opacity-80'}>Not connected</span>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default ConnectedPlatforms;
|
|
66
|
+
|
|
67
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
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 GaugeCard({
|
|
10
|
+
title,
|
|
11
|
+
description,
|
|
12
|
+
percent = 0,
|
|
13
|
+
label,
|
|
14
|
+
topMovers,
|
|
15
|
+
topMoversTitle,
|
|
16
|
+
tooltipText,
|
|
17
|
+
}: {
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
percent?: number;
|
|
21
|
+
label?: string;
|
|
22
|
+
topMovers?: TopMover[];
|
|
23
|
+
topMoversTitle?: string;
|
|
24
|
+
tooltipText?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const pct = Math.max(0, Math.min(100, Math.round(Number(percent ?? 0))));
|
|
27
|
+
const displayLabel = label || '';
|
|
28
|
+
const size = 280;
|
|
29
|
+
const strokeWidth = 32;
|
|
30
|
+
const radius = (size - strokeWidth) / 2;
|
|
31
|
+
const circumference = Math.PI * radius;
|
|
32
|
+
const progress = pct / 100;
|
|
33
|
+
const dash = circumference * progress;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={'rounded-md p-5 border flex flex-col'}
|
|
38
|
+
style={{
|
|
39
|
+
backgroundColor: 'var(--content-card-background)',
|
|
40
|
+
borderColor: 'var(--icon-button-secondary)',
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<div className="mb-3 flex items-start justify-between gap-2">
|
|
44
|
+
<div>
|
|
45
|
+
<div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
|
|
46
|
+
{description ? (
|
|
47
|
+
<div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>{description}</div>
|
|
48
|
+
) : null}
|
|
49
|
+
</div>
|
|
50
|
+
{(tooltipText || description) && (
|
|
51
|
+
<span className={'relative inline-flex items-center group cursor-help'} style={{ color: 'var(--text-secondary)' }}>
|
|
52
|
+
<FiInfo />
|
|
53
|
+
<div className="hidden group-hover:block absolute z-30 right-0 top-full mt-2 w-80">
|
|
54
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
55
|
+
<div style={{ fontWeight: 600 }}>{title}</div>
|
|
56
|
+
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {pct}%</div>
|
|
57
|
+
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>{tooltipText || description}</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
<div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 250 }}>
|
|
64
|
+
<div className="relative group" style={{ width: size, height: size / 2 }}>
|
|
65
|
+
<svg width={size} height={size / 2} viewBox={`0 0 ${size} ${size/2}`}>
|
|
66
|
+
<path d={`M ${strokeWidth/2} ${size/2} A ${radius} ${radius} 0 0 1 ${size-strokeWidth/2} ${size/2}`} stroke={'var(--icon-button-secondary)'} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" />
|
|
67
|
+
<path d={`M ${strokeWidth/2} ${size/2} A ${radius} ${radius} 0 0 1 ${size-strokeWidth/2} ${size/2}`} stroke={'var(--bubble-foreground)'} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" strokeDasharray={`${dash},${circumference}`} />
|
|
68
|
+
<line x1={size/2} y1={size/2} x2={size/2 + radius * Math.cos(Math.PI * progress - Math.PI)} y2={size/2 + radius * Math.sin(Math.PI * progress - Math.PI)} stroke={'var(--text-main)'} strokeWidth="2" />
|
|
69
|
+
</svg>
|
|
70
|
+
{(tooltipText || description) && (
|
|
71
|
+
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
|
|
72
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
73
|
+
<div style={{ fontWeight: 600 }}>{title}</div>
|
|
74
|
+
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {pct}%</div>
|
|
75
|
+
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>{tooltipText || description}</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
<div className={'mt-3 text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{displayLabel}</div>
|
|
81
|
+
</div>
|
|
82
|
+
{Array.isArray(topMovers) && topMovers.length > 0 && (
|
|
83
|
+
<div className="mt-4 text-center">
|
|
84
|
+
<div className={'text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{topMoversTitle || 'Top Score Movers'}</div>
|
|
85
|
+
<div className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
|
86
|
+
{topMovers.map((t, idx: number) => (
|
|
87
|
+
<React.Fragment key={idx}>
|
|
88
|
+
<BusinessRuleLink uid={t?.uid} label={t?.label || ''} />
|
|
89
|
+
{idx < topMovers.length - 1 && <span className="mx-2">|</span>}
|
|
90
|
+
</React.Fragment>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|