kyd-shared-badge 0.3.121 → 0.3.123

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.
@@ -40,6 +40,9 @@ export interface ConnectAccountsProps {
40
40
  githubAppSlugId: string;
41
41
  userId: string;
42
42
  inviteId?: string;
43
+ shouldShowContinueButton?: boolean;
44
+ shouldDisableContinueButton?: boolean;
45
+ onContinue?: () => void;
43
46
  }
44
47
 
45
48
 
@@ -8,86 +8,166 @@ export type ConnectProgressProps = {
8
8
  connectedIds: Set<string>
9
9
  className?: string
10
10
  layout?: 'fixed-desktop' | 'inline'
11
+ // Preview what the score would be if a connect/disconnect action occurred
12
+ previewProviderId?: string | null
13
+ previewAction?: 'connect' | 'disconnect' | null
14
+ // When a provider is selected (in the connect screen), show a pulsing gain segment
15
+ selectedProviderId?: string | null
11
16
  }
12
17
 
13
18
  export function ConnectProgress(props: ConnectProgressProps) {
14
- const { providers, connectedIds, className, layout = 'fixed-desktop' } = props
19
+ const { providers, connectedIds, className, layout = 'fixed-desktop', previewProviderId, previewAction, selectedProviderId } = props
15
20
 
16
- const hasGithub = connectedIds.has('github')
17
- const hasLinkedIn = connectedIds.has('linkedin')
21
+ const normalizeId = (s: string) => s.toLowerCase().replace(/[^a-z]/g, '')
22
+ const computeNormalizedSet = (ids: Set<string>) => {
23
+ const norm = new Set<string>()
24
+ ids.forEach(id => norm.add(normalizeId(id)))
25
+ return norm
26
+ }
27
+
28
+ const normalizedIds = useMemo(() => computeNormalizedSet(connectedIds), [connectedIds])
29
+ const hasGithub = normalizedIds.has('github')
30
+ const hasLinkedIn = normalizedIds.has('linkedin')
18
31
  const otherConnectedCount = useMemo(() => {
19
32
  let count = 0
20
- connectedIds.forEach(id => { if (id !== 'github' && id !== 'linkedin') count += 1 })
33
+ normalizedIds.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') count += 1 })
21
34
  return count
22
- }, [connectedIds])
35
+ }, [normalizedIds])
36
+
37
+ const computePercent = (ids: Set<string>) => {
38
+ const norm = computeNormalizedSet(ids)
39
+ const gh = norm.has('github')
40
+ const gha = norm.has('githubapp')
41
+ const li = norm.has('linkedin')
42
+ let others = 0
43
+ norm.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') others += 1 })
44
+ const githubWeight = gh ? 60 : 0
45
+ const githubAppWeight = gha ? 20 : 0
46
+ const linkedinWeight = li ? 0 : 0 // LinkedIn is not included in the score
47
+ const othersWeight = others * 10
48
+ return Math.max(0, Math.min(100, githubWeight + githubAppWeight + linkedinWeight + othersWeight))
49
+ }
23
50
 
24
- const progressPercent = useMemo(() => {
25
- const githubWeight = hasGithub ? 65 : 0
26
- const linkedinWeight = hasLinkedIn ? 15 : 0
27
- const othersWeight = Math.min(otherConnectedCount, 2) * 10
28
- return Math.max(0, Math.min(100, githubWeight + linkedinWeight + othersWeight))
29
- }, [hasGithub, hasLinkedIn, otherConnectedCount])
51
+ const progressPercent = useMemo(() => computePercent(connectedIds), [connectedIds])
52
+
53
+ const previewPercent = useMemo(() => {
54
+ const pid = normalizeId(previewProviderId || '')
55
+ if (!pid || !previewAction) return null as number | null
56
+ const next = new Set<string>(Array.from(normalizedIds))
57
+ const has = next.has(pid)
58
+ if (previewAction === 'connect' && !has) next.add(pid)
59
+ if (previewAction === 'disconnect' && has) next.delete(pid)
60
+ const val = computePercent(next)
61
+ if (process.env.NODE_ENV !== 'production') {
62
+ try {
63
+ // Debug details for preview behavior
64
+ const beforePercent = progressPercent
65
+ const beforeOthers = (() => {
66
+ let c = 0; normalizedIds.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') c += 1 })
67
+ return c
68
+ })()
69
+ let afterOthers = 0; next.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') afterOthers += 1 })
70
+ // eslint-disable-next-line no-console
71
+ console.log('[ConnectProgress] preview', {
72
+ pid,
73
+ previewAction,
74
+ hasBefore: normalizedIds.has(pid),
75
+ beforePercent,
76
+ afterPercent: val,
77
+ beforeOthers,
78
+ afterOthers,
79
+ normalizedIds: Array.from(normalizedIds),
80
+ nextIds: Array.from(next),
81
+ })
82
+ } catch {}
83
+ }
84
+ return val === progressPercent ? null : val
85
+ }, [previewProviderId, previewAction, normalizedIds, progressPercent])
30
86
 
31
87
  const progressCopy = useMemo(() => {
32
- const score = progressPercent
33
- if (score === 0) {
88
+ const score = previewPercent ?? progressPercent
89
+
90
+ if (score < 20) {
34
91
  return {
35
- headline: 'Get started — connect GitHub',
36
- body: 'GitHub contributes ~65% of your profile strength. Add LinkedIn (+15%) and 1–2 other platforms (+10% each). 70%+ is strong; 100% is optional.'
92
+ headline: 'Insufficient',
93
+ body: 'No meaningful data connections',
37
94
  }
38
- }
39
- if (hasGithub && score < 70) {
95
+ } else if (score < 40) {
40
96
  return {
41
- headline: 'Good base — add more signals',
42
- body: 'Add LinkedIn (+15%) and at least one other platform (+10%) to reach 70%+, which is considered strong. 100% is not mandatory.'
97
+ headline: 'Sparse',
98
+ body: 'Limited verified sources',
43
99
  }
44
- }
45
- if (!hasGithub && score < 70) {
100
+ } else if (score < 60) {
46
101
  return {
47
- headline: 'Add GitHub for a big boost',
48
- body: 'Current signals are light. Connecting GitHub adds ~65% weight and significantly improves your profile.'
102
+ headline: 'Adequate',
103
+ body: 'Adequate Minimum viable data coverage',
49
104
  }
50
- }
51
- if (score >= 70 && score < 90) {
105
+ } else if (score < 80) {
52
106
  return {
53
- headline: 'Strong — recommended coverage achieved',
54
- body: '70%+ indicates a strong profile. Adding more platforms can further improve verification. 100% is optional but even better.'
107
+ headline: 'Robust',
108
+ body: 'Strong, multi-source data foundation',
55
109
  }
56
- }
57
- if (score >= 90) {
110
+ } else {
58
111
  return {
59
- headline: 'Excellent — high confidence',
60
- body: 'You are near complete coverage. 100% is not required, but additional platforms can further increase confidence.'
112
+ headline: 'Optimal',
113
+ body: 'Comprehensive, high-confidence dataset',
61
114
  }
62
115
  }
63
- if (score === 100) {
64
- return {
65
- headline: 'Complete high confidence',
66
- body: 'You\'ve achieved the recommended coverage for a very strong profile! Connecting even more accounts can further strengthen your verification.'
67
- }
116
+ }, [previewPercent, progressPercent]);
117
+
118
+ const selectedPulse = useMemo(() => {
119
+ const sid = normalizeId(selectedProviderId || '')
120
+ if (!sid || sid === 'githubapp') return { from: undefined as number | undefined, to: undefined as number | undefined }
121
+ // Do not show if already connected
122
+ if (normalizedIds.has(sid)) return { from: undefined as number | undefined, to: undefined as number | undefined }
123
+ // Potential gain if they proceed with this provider
124
+ let gain = 0
125
+ if (sid === 'github') gain = 65
126
+ else if (sid === 'linkedin') gain = 15
127
+ else {
128
+ // Others always contribute +10 each (no cap)
129
+ const othersAlready = otherConnectedCount
130
+ gain = 10
68
131
  }
69
- return {
70
- headline: 'Keep going',
71
- body: 'Connect more platforms to strengthen your profile. 70%+ is strong; 100% is optional.'
132
+ const to = Math.max(0, Math.min(100, progressPercent + gain))
133
+ if (process.env.NODE_ENV !== 'production') {
134
+ try {
135
+ // eslint-disable-next-line no-console
136
+ console.log('[ConnectProgress] selectedPulse', {
137
+ sid,
138
+ progressPercent,
139
+ gain,
140
+ to,
141
+ othersAlready: otherConnectedCount,
142
+ })
143
+ } catch {}
72
144
  }
73
- }, [progressPercent, hasGithub])
145
+ return { from: progressPercent, to }
146
+ }, [selectedProviderId, normalizedIds, progressPercent, otherConnectedCount])
147
+
148
+ const isPreviewing = previewPercent !== null && previewPercent !== undefined
149
+ const valueToShow = previewPercent ?? progressPercent
74
150
 
75
151
  const content = (
76
152
  <Card className={`border-[var(--icon-button-secondary)] ${className || ''}`} style={{ backgroundColor: 'var(--content-card-background)'}}>
77
- <CardContent className="py-3 px-3">
78
- <div className="flex items-center gap-3">
79
- <ProgressCircle value={progressPercent} size={64} thickness={6} />
80
- <div className="min-w-0 max-w-xs">
81
- <div className="text-base font-semibold truncate" style={{ color: 'var(--text-main)'}}>{progressCopy.headline}</div>
82
- <div className="text-sm mt-0.5" style={{ color: 'var(--text-secondary)'}}>
153
+ <div className="p-4">
154
+ <div className="flex items-start gap-3">
155
+ <ProgressCircle
156
+ value={valueToShow}
157
+ size={64}
158
+ thickness={6}
159
+ highlightFrom={isPreviewing ? undefined : selectedPulse.from}
160
+ highlightTo={isPreviewing ? undefined : selectedPulse.to}
161
+ highlightPulse={Boolean(selectedPulse.to) && !isPreviewing}
162
+ />
163
+ <div className="min-w-0 max-w-xs flex flex-col gap-2 items-start justify-start">
164
+ <div className="text-base font-semibold truncate text-[var(--text-main)]">{progressCopy.headline}</div>
165
+ <div className="text-sm text-[var(--text-secondary)]">
83
166
  {progressCopy.body}
84
167
  </div>
85
- <div className="text-sm mt-1" style={{ color: 'var(--text-secondary)'}}>
86
- Weighted score • {progressPercent}%
87
- </div>
88
168
  </div>
89
169
  </div>
90
- </CardContent>
170
+ </div>
91
171
  </Card>
92
172
  )
93
173
 
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
  import React from 'react'
3
+ import { scoreToCssVar } from '../colors'
3
4
 
4
5
  export type ProgressCircleProps = {
5
6
  value: number
@@ -8,10 +9,14 @@ export type ProgressCircleProps = {
8
9
  className?: string
9
10
  showLabel?: boolean
10
11
  label?: string
12
+ // Optional highlight segment (e.g., to preview gain from an action)
13
+ highlightFrom?: number
14
+ highlightTo?: number
15
+ highlightPulse?: boolean
11
16
  }
12
17
 
13
18
  export function ProgressCircle(props: ProgressCircleProps) {
14
- const { value, size = 72, thickness = 6, className, showLabel = true, label } = props
19
+ const { value, size = 72, thickness = 6, className, showLabel = true, label, highlightFrom, highlightTo, highlightPulse } = props
15
20
 
16
21
  const rawId = React.useId()
17
22
  const gradientId = React.useMemo(() => `kyd-pc-${String(rawId).replace(/:/g, '')}` , [rawId])
@@ -21,6 +26,14 @@ export function ProgressCircle(props: ProgressCircleProps) {
21
26
  const circumference = 2 * Math.PI * radius
22
27
  const dashOffset = circumference * (1 - clamped / 100)
23
28
 
29
+ const hasHighlight = Number.isFinite(highlightFrom as number) && Number.isFinite(highlightTo as number) && (typeof highlightFrom === 'number') && (typeof highlightTo === 'number') && highlightTo! > (highlightFrom as number)
30
+ const fromClamped = hasHighlight ? Math.max(0, Math.min(100, highlightFrom as number)) : 0
31
+ const toClamped = hasHighlight ? Math.max(0, Math.min(100, highlightTo as number)) : 0
32
+ const highlightLength = hasHighlight ? circumference * ((toClamped - fromClamped) / 100) : 0
33
+ const highlightOffset = hasHighlight ? circumference * (1 - toClamped / 100) : 0
34
+
35
+ const strokeColor = React.useMemo(() => scoreToCssVar(clamped), [clamped])
36
+
24
37
  return (
25
38
  <div className={`relative inline-flex items-center justify-center ${className || ''}`} style={{ width: size, height: size }}>
26
39
  <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
@@ -44,34 +57,44 @@ export function ProgressCircle(props: ProgressCircleProps) {
44
57
  cy={size / 2}
45
58
  r={radius}
46
59
  fill="none"
47
- stroke={`url(#${gradientId})`}
60
+ stroke={strokeColor}
48
61
  strokeWidth={thickness}
49
62
  strokeLinecap="round"
50
63
  strokeDasharray={circumference}
51
64
  strokeDashoffset={dashOffset}
52
- style={{ transition: 'stroke-dashoffset 800ms ease' }}
65
+ style={{ transition: 'stroke-dashoffset 800ms ease, stroke 400ms ease, stroke-opacity 400ms ease' }}
53
66
  transform={`rotate(-90 ${size / 2} ${size / 2})`}
54
67
  />
68
+ {hasHighlight ? (
69
+ <circle
70
+ cx={size / 2}
71
+ cy={size / 2}
72
+ r={radius}
73
+ fill="none"
74
+ stroke="var(--icon-accent)"
75
+ strokeOpacity={0.6}
76
+ strokeWidth={thickness}
77
+ strokeLinecap="round"
78
+ strokeDasharray={`${highlightLength} ${circumference}`}
79
+ strokeDashoffset={highlightOffset}
80
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
81
+ className={highlightPulse ? 'animate-pulse' : ''}
82
+ />
83
+ ) : null}
55
84
  {/* subtle pulsing halo */}
56
85
  <circle
57
86
  cx={size / 2}
58
87
  cy={size / 2}
59
88
  r={radius + thickness / 2}
60
89
  fill="none"
61
- stroke="var(--icon-accent)"
90
+ stroke={strokeColor}
62
91
  strokeOpacity={0.15}
63
92
  strokeWidth={2}
64
93
  className="animate-pulse"
94
+ style={{ transition: 'stroke 400ms ease, stroke-opacity 400ms ease' }}
65
95
  />
66
96
  </svg>
67
- {showLabel ? (
68
- <div
69
- className="absolute inset-0 flex items-center justify-center text-[10px] sm:text-xs font-medium"
70
- style={{ color: 'var(--text-main)' }}
71
- >
72
- {label ? label : `${Math.round(clamped)}%`}
73
- </div>
74
- ) : null}
97
+
75
98
  </div>
76
99
  )
77
100
  }