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.
@@ -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
- : ["Country", "Entity Type", "Entity/Domain", "Searched On", "Value", "Findings"];
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 as any[]).map((src: any) => {
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
- : (sources as DomainCSVRow[]).map(s => ({
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 = sortedParsedSources.slice(0, visibleCount);
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: '45%' } : {}} />
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
- return <DomainRow key={index} source={source as DomainSource} searchedAt={formattedDate} developerName={developerName} />
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
+