kyd-shared-badge 0.3.120 → 0.3.122
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/connect/ConnectAccounts.tsx +437 -463
- package/src/connect/types.ts +3 -0
- package/src/ui/connect-progress.tsx +71 -11
- package/src/ui/progress-circle.tsx +33 -3
package/package.json
CHANGED
|
@@ -3,9 +3,9 @@ import { useRouter } from 'next/navigation';
|
|
|
3
3
|
import { ProviderIcon } from '../utils/provider';
|
|
4
4
|
import { normalizeLinkedInInput } from './linkedin';
|
|
5
5
|
import type { ConnectAccountsProps } from './types';
|
|
6
|
-
import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink,
|
|
7
|
-
import { AnimatePresence, motion } from 'framer-motion';
|
|
8
|
-
import { Button, Input, Spinner, Card, CardHeader, CardContent,
|
|
6
|
+
import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink, Eye, Lock, InfoIcon } from 'lucide-react';
|
|
7
|
+
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
|
8
|
+
import { Button, Input, Spinner, Card, CardHeader, CardContent, CardTitle, ConnectProgress } from '../ui';
|
|
9
9
|
import Link from 'next/link';
|
|
10
10
|
import { Tooltip, TooltipTrigger, TooltipProvider, TooltipContent } from '../ui/';
|
|
11
11
|
|
|
@@ -42,6 +42,9 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
42
42
|
githubAppSlugId,
|
|
43
43
|
userId,
|
|
44
44
|
inviteId,
|
|
45
|
+
shouldShowContinueButton,
|
|
46
|
+
shouldDisableContinueButton,
|
|
47
|
+
onContinue,
|
|
45
48
|
} = props;
|
|
46
49
|
|
|
47
50
|
const router = useRouter();
|
|
@@ -49,8 +52,9 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
49
52
|
const [linkUrl, setLinkUrl] = useState('');
|
|
50
53
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
51
54
|
const [isDisconnecting, setIsDisconnecting] = useState<string | null>(null);
|
|
52
|
-
const [showGithubManage, setShowGithubManage] = useState(false);
|
|
53
55
|
const [showDataHandling, setShowDataHandling] = useState(false);
|
|
56
|
+
const [previewProviderId, setPreviewProviderId] = useState<string | null>(null);
|
|
57
|
+
const [previewAction, setPreviewAction] = useState<'connect' | 'disconnect' | null>(null);
|
|
54
58
|
|
|
55
59
|
const apiBase = apiGatewayUrl || (typeof process !== 'undefined' ? (process.env.NEXT_PUBLIC_API_GATEWAY_URL as string) : '');
|
|
56
60
|
const connectedIds = useMemo(
|
|
@@ -63,8 +67,6 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
63
67
|
useEffect(() => {
|
|
64
68
|
if (initialProviderId && initialProviderId !== selectedProviderId) {
|
|
65
69
|
setSelectedProviderIdAndCallback(initialProviderId);
|
|
66
|
-
// If we landed here from initialProviderId, show the existing card (not manage)
|
|
67
|
-
if (initialProviderId === 'githubapp') setShowGithubManage(false);
|
|
68
70
|
}
|
|
69
71
|
// Do not clear selection if initialProviderId becomes falsy later
|
|
70
72
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -114,7 +116,6 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
114
116
|
});
|
|
115
117
|
const data = await res.json().catch(() => ({}));
|
|
116
118
|
if (!res.ok) throw new Error(data?.error || `Failed to disconnect ${providerId}.`);
|
|
117
|
-
setShowGithubManage(false);
|
|
118
119
|
if (onDisconnected) onDisconnected(providerId);
|
|
119
120
|
} catch (e) {
|
|
120
121
|
const err = e as Error;
|
|
@@ -194,10 +195,18 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
194
195
|
) || null;
|
|
195
196
|
}, [selectedProviderId, providers]);
|
|
196
197
|
|
|
198
|
+
const setPreview = (providerId: string, action: 'connect' | 'disconnect') => {
|
|
199
|
+
setPreviewProviderId(providerId.toLowerCase());
|
|
200
|
+
setPreviewAction(action);
|
|
201
|
+
};
|
|
202
|
+
const clearPreview = () => {
|
|
203
|
+
setPreviewProviderId(null);
|
|
204
|
+
setPreviewAction(null);
|
|
205
|
+
};
|
|
206
|
+
|
|
197
207
|
const handleConnectBack = () => {
|
|
198
208
|
setSelectedProviderIdAndCallback(null);
|
|
199
209
|
setLinkUrl('');
|
|
200
|
-
setShowGithubManage(false);
|
|
201
210
|
setShowDataHandling(false);
|
|
202
211
|
};
|
|
203
212
|
|
|
@@ -206,11 +215,18 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
206
215
|
animate: { opacity: 1, y: 0 },
|
|
207
216
|
exit: { opacity: 0, y: -20 },
|
|
208
217
|
};
|
|
218
|
+
const shouldReduceMotion = useReducedMotion();
|
|
219
|
+
const ease = [0.22, 1, 0.36, 1] as const;
|
|
209
220
|
const fadeOnly = {
|
|
210
221
|
initial: { opacity: 0 },
|
|
211
222
|
animate: { opacity: 1 },
|
|
212
223
|
exit: { opacity: 0 },
|
|
213
224
|
};
|
|
225
|
+
const fadeIn = {
|
|
226
|
+
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 8 },
|
|
227
|
+
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease } },
|
|
228
|
+
exit: { opacity: 0, y: shouldReduceMotion ? 0 : -4, transition: { duration: 0.2, ease } }
|
|
229
|
+
};
|
|
214
230
|
|
|
215
231
|
// GitHub status helpers
|
|
216
232
|
const githubConnectedAccount = useMemo(() => {
|
|
@@ -225,493 +241,451 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
225
241
|
// Progress UI is outsourced to ConnectProgress
|
|
226
242
|
|
|
227
243
|
return (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
<AnimatePresence initial={false} mode="wait">
|
|
242
|
-
{showDataHandling ? (
|
|
243
|
-
<motion.div
|
|
244
|
-
key="data-handling-card"
|
|
245
|
-
className="rounded-xl border max-w-xl w-full"
|
|
246
|
-
initial="initial" animate="animate" exit="exit" variants={fadeOnly}
|
|
247
|
-
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
|
|
248
|
-
>
|
|
249
|
-
<div className="sm:p-6 p-4">
|
|
250
|
-
<button onClick={() => setShowDataHandling(false)} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
|
|
251
|
-
<ArrowLeft className="w-4 h-4" />
|
|
252
|
-
Back
|
|
253
|
-
</button>
|
|
254
|
-
<div className="text-center">
|
|
255
|
-
<div className="flex justify-center mb-4">
|
|
256
|
-
<InfoIcon className="w-10 h-10 text-[var(--text-main)]" />
|
|
257
|
-
</div>
|
|
258
|
-
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
|
|
259
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
|
|
260
|
-
We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account. The app has
|
|
261
|
-
<span className="mx-1"><Link href="https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>Contents <ExternalLink className="size-3 inline-block" /></Link></span>
|
|
262
|
-
read access - only to the repositories you select. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
|
|
263
|
-
</p>
|
|
264
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
|
|
265
|
-
For other details, see our{' '}
|
|
266
|
-
<Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
|
|
267
|
-
Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
|
|
268
|
-
</Link>.
|
|
269
|
-
</p>
|
|
270
|
-
</div>
|
|
244
|
+
<div className="w-full grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] lg:justify-center gap-6 items-start max-w-7xl">
|
|
245
|
+
<div className="w-full flex justify-end">
|
|
246
|
+
<div className="w-full max-w-lg">
|
|
247
|
+
{/* Mobile: show progress above accounts, left-aligned */}
|
|
248
|
+
<div className="lg:hidden mb-4">
|
|
249
|
+
<ConnectProgress
|
|
250
|
+
layout="inline"
|
|
251
|
+
providers={providers}
|
|
252
|
+
connectedIds={connectedIds}
|
|
253
|
+
previewProviderId={previewProviderId}
|
|
254
|
+
previewAction={previewAction}
|
|
255
|
+
selectedProviderId={selectedProvider?.id || null}
|
|
256
|
+
/>
|
|
271
257
|
</div>
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
Back
|
|
284
|
-
</button>
|
|
285
|
-
<div className="text-center">
|
|
286
|
-
<div className="flex justify-center mb-4">
|
|
287
|
-
<ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
|
|
288
|
-
</div>
|
|
289
|
-
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
|
|
290
|
-
{selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
|
|
291
|
-
? `Use Public ${selectedProvider.name} Profile`
|
|
292
|
-
: `Connect ${selectedProvider.name}`}
|
|
293
|
-
</h3>
|
|
294
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
|
|
295
|
-
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
|
|
296
|
-
? (selectedProvider.placeholder || 'Enter your public profile URL.')
|
|
297
|
-
: `Authorize with ${selectedProvider.name} to connect your account.`}
|
|
298
|
-
</p>
|
|
299
|
-
</div>
|
|
300
|
-
|
|
301
|
-
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
|
|
302
|
-
<motion.form
|
|
303
|
-
onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
|
|
304
|
-
className="mt-6 space-y-4"
|
|
305
|
-
initial="initial" animate="animate" exit="exit" variants={cardVariants}
|
|
306
|
-
>
|
|
307
|
-
{selectedProvider.id === 'linkedin' && (
|
|
308
|
-
<>
|
|
309
|
-
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
310
|
-
<Link
|
|
311
|
-
href="https://www.linkedin.com/public-profile/settings"
|
|
312
|
-
target="_blank"
|
|
313
|
-
rel="noopener noreferrer"
|
|
314
|
-
className="underline"
|
|
315
|
-
style={{ color: 'var(--icon-accent)' }}
|
|
316
|
-
>
|
|
317
|
-
LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
|
|
318
|
-
</Link>
|
|
319
|
-
. This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
|
|
320
|
-
</p>
|
|
321
|
-
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
322
|
-
LinkedIn data is not used to contribute to your score.
|
|
323
|
-
</p>
|
|
324
|
-
</>
|
|
325
|
-
|
|
326
|
-
)}
|
|
327
|
-
<div className="relative">
|
|
328
|
-
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
|
|
329
|
-
<Input
|
|
330
|
-
type="url"
|
|
331
|
-
value={linkUrl}
|
|
332
|
-
onChange={(e) => setLinkUrl(e.target.value)}
|
|
333
|
-
placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
|
|
334
|
-
required
|
|
335
|
-
className="w-full border bg-transparent p-2 pl-9"
|
|
336
|
-
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
337
|
-
onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
|
|
338
|
-
onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
|
|
339
|
-
/>
|
|
340
|
-
</div>
|
|
341
|
-
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
342
|
-
<Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
|
|
343
|
-
{isSubmitting ? (
|
|
344
|
-
<div className="flex items-center justify-center">
|
|
345
|
-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
346
|
-
Connecting...
|
|
347
|
-
</div>
|
|
348
|
-
) : (
|
|
349
|
-
'Connect'
|
|
350
|
-
)}
|
|
351
|
-
</Button>
|
|
352
|
-
</motion.div>
|
|
353
|
-
</motion.form>
|
|
354
|
-
) : (
|
|
355
|
-
<div className="mt-6">
|
|
356
|
-
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
357
|
-
<Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
|
|
358
|
-
<ExternalLink className="w-4 h-4 mr-2" />
|
|
359
|
-
Connect with {selectedProvider.name}
|
|
360
|
-
</Button>
|
|
361
|
-
</motion.div>
|
|
362
|
-
</div>
|
|
363
|
-
)}
|
|
364
|
-
</div>
|
|
365
|
-
</motion.div>
|
|
366
|
-
) : selectedProvider && selectedProvider.id === 'githubapp' ? (
|
|
367
|
-
(!showGithubManage && initialProviderId === 'githubapp') ? (
|
|
368
|
-
<div
|
|
369
|
-
key="github-card"
|
|
370
|
-
className="rounded-xl border max-w-xl w-full"
|
|
371
|
-
style={{
|
|
372
|
-
backgroundColor: 'var(--content-card-background)',
|
|
373
|
-
borderColor: 'var(--icon-button-secondary)',
|
|
374
|
-
}}
|
|
375
|
-
>
|
|
376
|
-
<motion.div className="p-6 flex flex-col items-center" initial="initial" animate="animate" exit="exit" variants={fadeOnly}>
|
|
377
|
-
<div className="w-full flex items-center gap-3 mb-2 justify-center">
|
|
378
|
-
<ProviderIcon name="github" className="w-8 h-8 inline-block" />
|
|
379
|
-
<span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
|
|
380
|
-
</div>
|
|
381
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
|
|
382
|
-
You've successfully linked your GitHub account!
|
|
383
|
-
<br />
|
|
384
|
-
To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you'd like to highlight private work or share additional contributions for verification.
|
|
385
|
-
<br /><br />
|
|
386
|
-
<span className="text-[var(--text-main)] font-medium">
|
|
387
|
-
Would you like to connect your private repositories?
|
|
388
|
-
</span>
|
|
389
|
-
<button
|
|
390
|
-
type="button"
|
|
391
|
-
onClick={() => setShowDataHandling(true)}
|
|
392
|
-
className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
|
|
393
|
-
>
|
|
394
|
-
How KYD Handles Your Data
|
|
258
|
+
{showDataHandling ? (
|
|
259
|
+
<motion.div
|
|
260
|
+
key="data-handling-card"
|
|
261
|
+
className="rounded-xl border max-w-xl w-full"
|
|
262
|
+
initial="initial" animate="animate" exit="exit" variants={fadeOnly}
|
|
263
|
+
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
|
|
264
|
+
>
|
|
265
|
+
<div className="sm:p-6 p-4">
|
|
266
|
+
<button onClick={() => setShowDataHandling(false)} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
|
|
267
|
+
<ArrowLeft className="w-4 h-4" />
|
|
268
|
+
Back
|
|
395
269
|
</button>
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
>
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
>
|
|
414
|
-
<span className="flex items-center justify-center">
|
|
415
|
-
<ExternalLink className="w-4 h-4 mr-2" />
|
|
416
|
-
Yes, connect my private repos
|
|
417
|
-
</span>
|
|
418
|
-
</Button>
|
|
419
|
-
</motion.div>
|
|
270
|
+
<div className="text-center">
|
|
271
|
+
<div className="flex justify-center mb-4">
|
|
272
|
+
<InfoIcon className="w-10 h-10 text-[var(--text-main)]" />
|
|
273
|
+
</div>
|
|
274
|
+
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
|
|
275
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
|
|
276
|
+
We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account. The app has
|
|
277
|
+
<span className="mx-1"><Link href="https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>Contents <ExternalLink className="size-3 inline-block" /></Link></span>
|
|
278
|
+
read access - only to the repositories you select. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
|
|
279
|
+
</p>
|
|
280
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
|
|
281
|
+
For other details, see our{' '}
|
|
282
|
+
<Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
|
|
283
|
+
Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
|
|
284
|
+
</Link>.
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
420
287
|
</div>
|
|
421
288
|
</motion.div>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
289
|
+
) : selectedProvider && selectedProvider.id !== 'githubapp' ? (
|
|
290
|
+
<motion.div
|
|
291
|
+
key="connect-card"
|
|
292
|
+
initial="initial" animate="animate" exit="exit" variants={fadeOnly}
|
|
293
|
+
className="rounded-xl border max-w-xl w-full"
|
|
294
|
+
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
|
|
295
|
+
>
|
|
296
|
+
<div className="sm:p-6 p-4">
|
|
297
|
+
<button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
|
|
298
|
+
<ArrowLeft className="w-4 h-4" />
|
|
299
|
+
Back
|
|
300
|
+
</button>
|
|
301
|
+
<div className="text-center">
|
|
302
|
+
<div className="flex justify-center mb-4">
|
|
303
|
+
<ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
|
|
304
|
+
</div>
|
|
305
|
+
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
|
|
306
|
+
{selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
|
|
307
|
+
? `Use Public ${selectedProvider.name} Profile`
|
|
308
|
+
: `Connect ${selectedProvider.name}`}
|
|
309
|
+
</h3>
|
|
310
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
|
|
311
|
+
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
|
|
312
|
+
? (selectedProvider.placeholder || 'Enter your public profile URL.')
|
|
313
|
+
: `Authorize with ${selectedProvider.name} to connect your account.`}
|
|
314
|
+
</p>
|
|
441
315
|
</div>
|
|
442
|
-
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>Manage GitHub Connections</h3>
|
|
443
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md">
|
|
444
|
-
Connect or disconnect your GitHub OAuth account and optional GitHub App for private repositories.
|
|
445
|
-
</p>
|
|
446
|
-
</div>
|
|
447
316
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
<
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
)
|
|
317
|
+
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
|
|
318
|
+
<motion.form
|
|
319
|
+
onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
|
|
320
|
+
className="mt-6 space-y-4"
|
|
321
|
+
initial="initial" animate="animate" exit="exit" variants={cardVariants}
|
|
322
|
+
>
|
|
323
|
+
{selectedProvider.id === 'linkedin' && (
|
|
324
|
+
<>
|
|
325
|
+
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
326
|
+
<Link
|
|
327
|
+
href="https://www.linkedin.com/public-profile/settings"
|
|
328
|
+
target="_blank"
|
|
329
|
+
rel="noopener noreferrer"
|
|
330
|
+
className="underline"
|
|
331
|
+
style={{ color: 'var(--icon-accent)' }}
|
|
332
|
+
>
|
|
333
|
+
LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
|
|
334
|
+
</Link>
|
|
335
|
+
. This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
|
|
336
|
+
</p>
|
|
337
|
+
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
338
|
+
LinkedIn data is not used to contribute to your score.
|
|
339
|
+
</p>
|
|
340
|
+
</>
|
|
341
|
+
|
|
342
|
+
)}
|
|
343
|
+
<div className="relative">
|
|
344
|
+
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
|
|
345
|
+
<Input
|
|
346
|
+
type="url"
|
|
347
|
+
value={linkUrl}
|
|
348
|
+
onChange={(e) => setLinkUrl(e.target.value)}
|
|
349
|
+
placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
|
|
350
|
+
required
|
|
351
|
+
className="w-full border bg-transparent p-2 pl-9"
|
|
352
|
+
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
353
|
+
onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
|
|
354
|
+
onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
|
|
355
|
+
/>
|
|
476
356
|
</div>
|
|
357
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
358
|
+
<Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
|
|
359
|
+
{isSubmitting ? (
|
|
360
|
+
<div className="flex items-center justify-center">
|
|
361
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
362
|
+
Connecting...
|
|
363
|
+
</div>
|
|
364
|
+
) : (
|
|
365
|
+
'Connect'
|
|
366
|
+
)}
|
|
367
|
+
</Button>
|
|
368
|
+
</motion.div>
|
|
369
|
+
</motion.form>
|
|
370
|
+
) : (
|
|
371
|
+
<div className="mt-6">
|
|
372
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
373
|
+
<Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
|
|
374
|
+
<ExternalLink className="w-4 h-4 mr-2" />
|
|
375
|
+
Connect with {selectedProvider.name}
|
|
376
|
+
</Button>
|
|
377
|
+
</motion.div>
|
|
477
378
|
</div>
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
</motion.div>
|
|
382
|
+
) : selectedProvider && selectedProvider.id === 'githubapp' ? (
|
|
383
|
+
<div
|
|
384
|
+
key="github-card"
|
|
385
|
+
className="rounded-xl border max-w-xl w-full"
|
|
386
|
+
style={{
|
|
387
|
+
backgroundColor: 'var(--content-card-background)',
|
|
388
|
+
borderColor: 'var(--icon-button-secondary)',
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
<motion.div className="p-6 flex flex-col items-center" initial="initial" animate="animate" exit="exit" variants={fadeOnly}>
|
|
392
|
+
<div className="w-full flex items-center gap-3 mb-2 justify-center">
|
|
393
|
+
<ProviderIcon name="github" className="w-8 h-8 inline-block" />
|
|
394
|
+
<span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
|
|
395
|
+
</div>
|
|
396
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
|
|
397
|
+
You've successfully linked your GitHub account!
|
|
398
|
+
<br />
|
|
399
|
+
To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you'd like to highlight private work or share additional contributions for verification.
|
|
400
|
+
<br /><br />
|
|
401
|
+
<span className="text-[var(--text-main)] font-medium">
|
|
402
|
+
Would you like to connect your private repositories?
|
|
403
|
+
</span>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
onClick={() => setShowDataHandling(true)}
|
|
407
|
+
className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
|
|
408
|
+
>
|
|
409
|
+
How KYD Handles Your Data
|
|
410
|
+
</button>
|
|
411
|
+
</p>
|
|
412
|
+
<div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
|
|
413
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
484
414
|
<Button
|
|
485
|
-
className="
|
|
486
|
-
|
|
487
|
-
|
|
415
|
+
className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
|
|
416
|
+
variant="destructive"
|
|
417
|
+
onClick={() => {
|
|
418
|
+
handleConnectBack();
|
|
419
|
+
}}
|
|
488
420
|
>
|
|
489
|
-
|
|
421
|
+
No, don't connect
|
|
490
422
|
</Button>
|
|
491
|
-
</div>
|
|
492
|
-
<div
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
variant="destructive"
|
|
499
|
-
>
|
|
500
|
-
<Unlink className="size-3 sm:size-4" />
|
|
501
|
-
<span>Uninstall</span>
|
|
502
|
-
</Button>
|
|
503
|
-
) : (
|
|
504
|
-
<Button
|
|
505
|
-
onClick={onGithubAppInstall}
|
|
506
|
-
className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
|
|
507
|
-
>
|
|
423
|
+
</motion.div>
|
|
424
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
425
|
+
<Button
|
|
426
|
+
className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
|
|
427
|
+
onClick={onGithubAppInstall}
|
|
428
|
+
>
|
|
429
|
+
<span className="flex items-center justify-center">
|
|
508
430
|
<ExternalLink className="w-4 h-4 mr-2" />
|
|
509
|
-
|
|
510
|
-
</
|
|
511
|
-
|
|
512
|
-
</div>
|
|
431
|
+
Yes, connect my private repos
|
|
432
|
+
</span>
|
|
433
|
+
</Button>
|
|
434
|
+
</motion.div>
|
|
513
435
|
</div>
|
|
514
|
-
</div>
|
|
436
|
+
</motion.div>
|
|
515
437
|
</div>
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return (
|
|
569
|
-
<div key={providerId} className="group flex items-center justify-between p-2 rounded-lg transition-colors hover:bg-[var(--icon-button-secondary)]/10 transform transition-transform hover:-translate-y-px">
|
|
570
|
-
<div className="flex items-center gap-3">
|
|
571
|
-
|
|
572
|
-
{isConnected && connectedUrl ? (
|
|
573
|
-
<Link href={connectedUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:underline">
|
|
574
|
-
<ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
575
|
-
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
576
|
-
{isRequired && !isConnected ? (requiredFlag()) : null}
|
|
577
|
-
</Link>
|
|
578
|
-
) : (
|
|
438
|
+
) : (
|
|
439
|
+
<Card className="border-[var(--icon-button-secondary)] pt-2" style={{ backgroundColor: 'var(--content-card-background)'}}>
|
|
440
|
+
<AnimatePresence mode="wait">
|
|
441
|
+
<motion.div key="platform-list-shared" variants={cardVariants} initial="initial" animate="animate" exit="exit" transition={{ duration: 0.3 }}>
|
|
442
|
+
<CardHeader className="pb-4">
|
|
443
|
+
{handleBackButton && (
|
|
444
|
+
<button onClick={() => handleBackButton()} className="flex items-center gap-2 text-sm mb-4 text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors">
|
|
445
|
+
<ArrowLeft className="w-4 h-4" /> Back
|
|
446
|
+
</button>
|
|
447
|
+
)}
|
|
448
|
+
<CardTitle className="sm:text-xl text-base font-semibold text-[var(--text-main)] mb-2">{headerTitle}</CardTitle>
|
|
449
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed">{headerDescription}</p>
|
|
450
|
+
</CardHeader>
|
|
451
|
+
<CardContent className="space-y-2">
|
|
452
|
+
{(() => {
|
|
453
|
+
const oauthList = list.filter(p => p.connectionType === 'oauth');
|
|
454
|
+
const urlList = list.filter(p => (p.connectionType || 'url') === 'url' || p.connectionType === 'link');
|
|
455
|
+
|
|
456
|
+
const Row = (provider: typeof list[number]) => {
|
|
457
|
+
const providerId = provider.id;
|
|
458
|
+
const isRequired = requiredProviders?.includes(providerId);
|
|
459
|
+
const isConnected = connectedIds.has(providerId.toLowerCase());
|
|
460
|
+
const needsReconnect = reconnectIds.has(providerId.toLowerCase());
|
|
461
|
+
const isOauth = provider.connectionType === 'oauth'
|
|
462
|
+
const connectedUrl = connected.find(c => c.name.toLowerCase() === providerId.toLowerCase())?.url;
|
|
463
|
+
const betaFlag = () => (
|
|
464
|
+
<span
|
|
465
|
+
className="ml-2 inline-block rounded-full px-2 py-0.5 text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200"
|
|
466
|
+
style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
|
|
467
|
+
>
|
|
468
|
+
Beta
|
|
469
|
+
</span>
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const requiredFlag = () => (
|
|
473
|
+
<TooltipProvider>
|
|
474
|
+
<Tooltip>
|
|
475
|
+
<TooltipTrigger>
|
|
476
|
+
<div className="flex items-center justify-center size-4 sm:size-5 rounded-full bg-[var(--icon-accent)] cursor-help">
|
|
477
|
+
<span className="text-white text-xs font-bold -translate-y-px select-none">!</span>
|
|
478
|
+
</div>
|
|
479
|
+
</TooltipTrigger>
|
|
480
|
+
<TooltipContent>
|
|
481
|
+
<p>Required by <span className="font-semibold">{companyName || 'your recruiter'}</span></p>
|
|
482
|
+
</TooltipContent>
|
|
483
|
+
</Tooltip>
|
|
484
|
+
</TooltipProvider>
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<div key={providerId} className="group flex items-center justify-between p-2 rounded-lg transition-colors hover:bg-[var(--icon-button-secondary)]/10 transform transition-transform hover:-translate-y-px">
|
|
579
489
|
<div className="flex items-center gap-3">
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
490
|
+
|
|
491
|
+
{isConnected && connectedUrl ? (
|
|
492
|
+
<Link href={connectedUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:underline">
|
|
493
|
+
<ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
494
|
+
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
495
|
+
{isRequired && !isConnected ? (requiredFlag()) : null}
|
|
496
|
+
</Link>
|
|
497
|
+
) : (
|
|
498
|
+
<div className="flex items-center gap-3">
|
|
499
|
+
<ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
500
|
+
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
501
|
+
{isRequired && !isConnected ? (requiredFlag()) : null}
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
{provider.beta ? betaFlag() : null}
|
|
583
505
|
</div>
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
506
|
+
|
|
507
|
+
{providerId.toLowerCase() === 'github' ? (
|
|
508
|
+
<div className="flex items-center gap-2">
|
|
509
|
+
{/* Public (OAuth) */}
|
|
510
|
+
{isGithubConnected ? (
|
|
511
|
+
<div className="relative flex items-center">
|
|
512
|
+
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
513
|
+
<CheckCircle className="size-3 sm:size-4" />
|
|
514
|
+
<span className="text-sm font-medium">Connected</span>
|
|
515
|
+
</div>
|
|
516
|
+
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
517
|
+
<button
|
|
518
|
+
onClick={() => onDisconnect('github')}
|
|
519
|
+
onMouseEnter={() => setPreview('github', 'disconnect')}
|
|
520
|
+
onMouseLeave={clearPreview}
|
|
521
|
+
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
522
|
+
disabled={isDisconnecting === 'github'}
|
|
523
|
+
>
|
|
524
|
+
{isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
|
|
525
|
+
<span>Disconnect</span>
|
|
526
|
+
</button>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
) : (
|
|
530
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
531
|
+
<Button
|
|
532
|
+
onClick={() => setSelectedProviderIdAndCallback('github')}
|
|
533
|
+
onMouseEnter={() => setPreview('github', 'connect')}
|
|
534
|
+
onMouseLeave={clearPreview}
|
|
535
|
+
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
536
|
+
>
|
|
537
|
+
<Eye className="size-3 sm:size-4" />
|
|
538
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
539
|
+
</Button>
|
|
540
|
+
</motion.div>
|
|
541
|
+
)}
|
|
542
|
+
|
|
543
|
+
{/* Private (GitHub App) */}
|
|
544
|
+
{isGithubAppInstalled ? (
|
|
545
|
+
<div className="relative flex items-center">
|
|
546
|
+
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
547
|
+
<CheckCircle className="size-3 sm:size-4" />
|
|
548
|
+
<span className="text-sm font-medium">Connected</span>
|
|
549
|
+
</div>
|
|
550
|
+
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
551
|
+
<button
|
|
552
|
+
onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
|
|
553
|
+
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
554
|
+
>
|
|
555
|
+
<Unlink className="size-3 sm:size-4" />
|
|
556
|
+
<span>Uninstall</span>
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
) : (
|
|
561
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
562
|
+
<Button
|
|
563
|
+
onClick={() => setSelectedProviderIdAndCallback('githubapp')}
|
|
564
|
+
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
565
|
+
>
|
|
566
|
+
<Lock className="size-3 sm:size-4" />
|
|
567
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
568
|
+
</Button>
|
|
569
|
+
</motion.div>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
572
|
+
) : isConnected && isDisconnecting !== providerId ? (
|
|
573
|
+
<div className="relative flex items-center">
|
|
614
574
|
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
615
575
|
<CheckCircle className="size-3 sm:size-4" />
|
|
616
576
|
<span className="text-sm font-medium">Connected</span>
|
|
617
577
|
</div>
|
|
618
578
|
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
619
|
-
<
|
|
579
|
+
<button
|
|
580
|
+
onClick={() => onDisconnect(providerId)}
|
|
581
|
+
onMouseEnter={() => setPreview(providerId, 'disconnect')}
|
|
582
|
+
onMouseLeave={clearPreview}
|
|
583
|
+
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
584
|
+
>
|
|
620
585
|
<Unlink className="size-3 sm:size-4" />
|
|
621
586
|
<span>Disconnect</span>
|
|
622
|
-
</
|
|
587
|
+
</button>
|
|
623
588
|
</div>
|
|
624
589
|
</div>
|
|
625
|
-
)
|
|
626
|
-
|
|
627
|
-
|
|
590
|
+
) : isDisconnecting === providerId ? (
|
|
591
|
+
<div className="relative flex items-center">
|
|
592
|
+
<Spinner />
|
|
593
|
+
</div>
|
|
594
|
+
) : (
|
|
595
|
+
<div className="flex items-center gap-2">
|
|
596
|
+
<>
|
|
597
|
+
<Button
|
|
598
|
+
onClick={() => setSelectedProviderIdAndCallback(providerId)}
|
|
599
|
+
onMouseEnter={() => setPreview(providerId, 'connect')}
|
|
600
|
+
onMouseLeave={clearPreview}
|
|
601
|
+
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
602
|
+
>
|
|
603
|
+
{isOauth ? (
|
|
604
|
+
<Link2 className="size-3 sm:size-4" />
|
|
605
|
+
) : (
|
|
606
|
+
<LinkIcon className="size-3 sm:size-4" />
|
|
607
|
+
)}
|
|
608
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
609
|
+
</Button>
|
|
610
|
+
{needsReconnect && !isRequired && (
|
|
611
|
+
<Button
|
|
612
|
+
onClick={() => onDisconnect(providerId)}
|
|
613
|
+
onMouseEnter={() => setPreview(providerId, 'disconnect')}
|
|
614
|
+
onMouseLeave={clearPreview}
|
|
615
|
+
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
|
|
616
|
+
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
617
|
+
variant="destructive"
|
|
618
|
+
>
|
|
619
|
+
<Unlink className="size-3 sm:size-4" />
|
|
620
|
+
<span>Remove</span>
|
|
621
|
+
</Button>
|
|
622
|
+
)}
|
|
623
|
+
</>
|
|
624
|
+
</div>
|
|
628
625
|
)}
|
|
629
626
|
</div>
|
|
630
|
-
)
|
|
631
|
-
|
|
632
|
-
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
633
|
-
<CheckCircle className="size-3 sm:size-4" />
|
|
634
|
-
<span className="text-sm font-medium">Connected</span>
|
|
635
|
-
</div>
|
|
636
|
-
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
637
|
-
<button
|
|
638
|
-
onClick={() => onDisconnect(providerId)}
|
|
639
|
-
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
640
|
-
>
|
|
641
|
-
<Unlink className="size-3 sm:size-4" />
|
|
642
|
-
<span>Disconnect</span>
|
|
643
|
-
</button>
|
|
644
|
-
</div>
|
|
645
|
-
</div>
|
|
646
|
-
) : isDisconnecting === providerId ? (
|
|
647
|
-
<div className="relative flex items-center">
|
|
648
|
-
<Spinner />
|
|
649
|
-
</div>
|
|
650
|
-
) : (
|
|
651
|
-
<div className="flex items-center gap-2">
|
|
652
|
-
<>
|
|
653
|
-
<Button
|
|
654
|
-
onClick={() => setSelectedProviderIdAndCallback(providerId)}
|
|
655
|
-
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
656
|
-
>
|
|
657
|
-
{isOauth ? (
|
|
658
|
-
<Link2 className="size-3 sm:size-4" />
|
|
659
|
-
) : (
|
|
660
|
-
<LinkIcon className="size-3 sm:size-4" />
|
|
661
|
-
)}
|
|
662
|
-
<span className="sm:text-base text-sm">Connect</span>
|
|
663
|
-
</Button>
|
|
664
|
-
{needsReconnect && !isRequired && (
|
|
665
|
-
<Button
|
|
666
|
-
onClick={() => onDisconnect(providerId)}
|
|
667
|
-
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
|
|
668
|
-
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
669
|
-
variant="destructive"
|
|
670
|
-
>
|
|
671
|
-
<Unlink className="size-3 sm:size-4" />
|
|
672
|
-
<span>Remove</span>
|
|
673
|
-
</Button>
|
|
674
|
-
)}
|
|
675
|
-
</>
|
|
676
|
-
</div>
|
|
677
|
-
)}
|
|
678
|
-
</div>
|
|
679
|
-
);
|
|
680
|
-
};
|
|
627
|
+
);
|
|
628
|
+
};
|
|
681
629
|
|
|
682
|
-
|
|
683
|
-
<>
|
|
684
|
-
{oauthList.length ? (
|
|
630
|
+
return (
|
|
685
631
|
<>
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
632
|
+
{oauthList.length ? (
|
|
633
|
+
<>
|
|
634
|
+
<div className="flex items-center my-2">
|
|
635
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
636
|
+
<span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>OAuth</span>
|
|
637
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
638
|
+
</div>
|
|
639
|
+
{oauthList.map(p => Row(p))}
|
|
640
|
+
</>
|
|
641
|
+
) : null}
|
|
694
642
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
643
|
+
{urlList.length ? (
|
|
644
|
+
<>
|
|
645
|
+
<div className="flex items-center my-2">
|
|
646
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
647
|
+
<span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>Public Urls</span>
|
|
648
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
649
|
+
</div>
|
|
650
|
+
{urlList.map(p => Row(p))}
|
|
651
|
+
</>
|
|
652
|
+
) : null}
|
|
703
653
|
</>
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
</
|
|
654
|
+
);
|
|
655
|
+
})()}
|
|
656
|
+
</CardContent>
|
|
657
|
+
</motion.div>
|
|
658
|
+
</AnimatePresence>
|
|
659
|
+
</Card>
|
|
660
|
+
)}
|
|
661
|
+
{shouldShowContinueButton && (
|
|
662
|
+
<motion.div className="mt-6 w-full" initial="hidden" animate="visible" variants={fadeIn}>
|
|
663
|
+
<motion.div whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }}>
|
|
664
|
+
<Button
|
|
665
|
+
onClick={onContinue}
|
|
666
|
+
disabled={shouldDisableContinueButton}
|
|
667
|
+
className="w-full text-white font-medium py-2.5 bg-[var(--icon-accent)] disabled:opacity-50"
|
|
668
|
+
>
|
|
669
|
+
Continue
|
|
670
|
+
</Button>
|
|
671
|
+
</motion.div>
|
|
709
672
|
</motion.div>
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
{/* Desktop: progress card to the right, sticky while scrolling */}
|
|
678
|
+
<div className="hidden lg:block lg:sticky lg:top-6">
|
|
679
|
+
<ConnectProgress
|
|
680
|
+
layout="inline"
|
|
681
|
+
providers={providers}
|
|
682
|
+
connectedIds={connectedIds}
|
|
683
|
+
previewProviderId={previewProviderId}
|
|
684
|
+
previewAction={previewAction}
|
|
685
|
+
selectedProviderId={selectedProvider?.id || null}
|
|
686
|
+
/>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
715
689
|
);
|
|
716
690
|
}
|
|
717
691
|
|
package/src/connect/types.ts
CHANGED
|
@@ -8,10 +8,15 @@ 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
21
|
const hasGithub = connectedIds.has('github')
|
|
17
22
|
const hasLinkedIn = connectedIds.has('linkedin')
|
|
@@ -21,12 +26,33 @@ export function ConnectProgress(props: ConnectProgressProps) {
|
|
|
21
26
|
return count
|
|
22
27
|
}, [connectedIds])
|
|
23
28
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
29
|
+
const computePercent = (ids: Set<string>) => {
|
|
30
|
+
const gh = ids.has('github')
|
|
31
|
+
const li = ids.has('linkedin')
|
|
32
|
+
let others = 0
|
|
33
|
+
ids.forEach(id => {
|
|
34
|
+
const lid = id.toLowerCase()
|
|
35
|
+
if (lid !== 'github' && lid !== 'linkedin') others += 1
|
|
36
|
+
})
|
|
37
|
+
const githubWeight = gh ? 65 : 0
|
|
38
|
+
const linkedinWeight = li ? 15 : 0
|
|
39
|
+
const othersWeight = Math.min(others, 2) * 10
|
|
28
40
|
return Math.max(0, Math.min(100, githubWeight + linkedinWeight + othersWeight))
|
|
29
|
-
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const progressPercent = useMemo(() => computePercent(connectedIds), [connectedIds])
|
|
44
|
+
|
|
45
|
+
const previewPercent = useMemo(() => {
|
|
46
|
+
const pid = (previewProviderId || '').toLowerCase()
|
|
47
|
+
if (!pid || !previewAction) return null as number | null
|
|
48
|
+
if (pid === 'githubapp') return null
|
|
49
|
+
const next = new Set<string>(Array.from(connectedIds))
|
|
50
|
+
const has = next.has(pid)
|
|
51
|
+
if (previewAction === 'connect' && !has) next.add(pid)
|
|
52
|
+
if (previewAction === 'disconnect' && has) next.delete(pid)
|
|
53
|
+
const val = computePercent(next)
|
|
54
|
+
return val === progressPercent ? null : val
|
|
55
|
+
}, [previewProviderId, previewAction, connectedIds, progressPercent])
|
|
30
56
|
|
|
31
57
|
const progressCopy = useMemo(() => {
|
|
32
58
|
const score = progressPercent
|
|
@@ -60,24 +86,57 @@ export function ConnectProgress(props: ConnectProgressProps) {
|
|
|
60
86
|
body: 'You are near complete coverage. 100% is not required, but additional platforms can further increase confidence.'
|
|
61
87
|
}
|
|
62
88
|
}
|
|
89
|
+
if (score === 100) {
|
|
90
|
+
return {
|
|
91
|
+
headline: 'Complete — high confidence',
|
|
92
|
+
body: 'You\'ve achieved the recommended coverage for a very strong profile! Connecting even more accounts can further strengthen your verification.'
|
|
93
|
+
}
|
|
94
|
+
}
|
|
63
95
|
return {
|
|
64
96
|
headline: 'Keep going',
|
|
65
97
|
body: 'Connect more platforms to strengthen your profile. 70%+ is strong; 100% is optional.'
|
|
66
98
|
}
|
|
67
99
|
}, [progressPercent, hasGithub])
|
|
68
100
|
|
|
101
|
+
const selectedPulse = useMemo(() => {
|
|
102
|
+
const sid = (selectedProviderId || '').toLowerCase()
|
|
103
|
+
if (!sid || sid === 'githubapp') return { from: undefined as number | undefined, to: undefined as number | undefined }
|
|
104
|
+
// Do not show if already connected
|
|
105
|
+
if (connectedIds.has(sid)) return { from: undefined as number | undefined, to: undefined as number | undefined }
|
|
106
|
+
// Potential gain if they proceed with this provider
|
|
107
|
+
let gain = 0
|
|
108
|
+
if (sid === 'github') gain = 65
|
|
109
|
+
else if (sid === 'linkedin') gain = 15
|
|
110
|
+
else {
|
|
111
|
+
// Others contribute up to 2*10 capped
|
|
112
|
+
const othersAlready = otherConnectedCount
|
|
113
|
+
if (othersAlready < 2) gain = 10
|
|
114
|
+
}
|
|
115
|
+
const to = Math.max(0, Math.min(100, progressPercent + gain))
|
|
116
|
+
return { from: progressPercent, to }
|
|
117
|
+
}, [selectedProviderId, connectedIds, progressPercent, otherConnectedCount])
|
|
118
|
+
|
|
119
|
+
const valueToShow = previewPercent ?? progressPercent
|
|
120
|
+
|
|
69
121
|
const content = (
|
|
70
122
|
<Card className={`border-[var(--icon-button-secondary)] ${className || ''}`} style={{ backgroundColor: 'var(--content-card-background)'}}>
|
|
71
123
|
<CardContent className="py-3 px-3">
|
|
72
124
|
<div className="flex items-center gap-3">
|
|
73
|
-
<ProgressCircle
|
|
125
|
+
<ProgressCircle
|
|
126
|
+
value={valueToShow}
|
|
127
|
+
size={64}
|
|
128
|
+
thickness={6}
|
|
129
|
+
highlightFrom={selectedPulse.from}
|
|
130
|
+
highlightTo={selectedPulse.to}
|
|
131
|
+
highlightPulse={Boolean(selectedPulse.to)}
|
|
132
|
+
/>
|
|
74
133
|
<div className="min-w-0 max-w-xs">
|
|
75
|
-
<div className="text-
|
|
76
|
-
<div className="text-
|
|
134
|
+
<div className="text-base font-semibold truncate" style={{ color: 'var(--text-main)'}}>{progressCopy.headline}</div>
|
|
135
|
+
<div className="text-sm mt-0.5" style={{ color: 'var(--text-secondary)'}}>
|
|
77
136
|
{progressCopy.body}
|
|
78
137
|
</div>
|
|
79
|
-
<div className="text-
|
|
80
|
-
Weighted score • {
|
|
138
|
+
<div className="text-sm mt-1" style={{ color: 'var(--text-secondary)'}}>
|
|
139
|
+
Weighted score • {valueToShow}%
|
|
81
140
|
</div>
|
|
82
141
|
</div>
|
|
83
142
|
</div>
|
|
@@ -95,3 +154,4 @@ export function ConnectProgress(props: ConnectProgressProps) {
|
|
|
95
154
|
export default ConnectProgress
|
|
96
155
|
|
|
97
156
|
|
|
157
|
+
|
|
@@ -8,21 +8,34 @@ export type ProgressCircleProps = {
|
|
|
8
8
|
className?: string
|
|
9
9
|
showLabel?: boolean
|
|
10
10
|
label?: string
|
|
11
|
+
// Optional highlight segment (e.g., to preview gain from an action)
|
|
12
|
+
highlightFrom?: number
|
|
13
|
+
highlightTo?: number
|
|
14
|
+
highlightPulse?: boolean
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export function ProgressCircle(props: ProgressCircleProps) {
|
|
14
|
-
const { value, size = 72, thickness = 6, className, showLabel = true, label } = props
|
|
18
|
+
const { value, size = 72, thickness = 6, className, showLabel = true, label, highlightFrom, highlightTo, highlightPulse } = props
|
|
19
|
+
|
|
20
|
+
const rawId = React.useId()
|
|
21
|
+
const gradientId = React.useMemo(() => `kyd-pc-${String(rawId).replace(/:/g, '')}` , [rawId])
|
|
15
22
|
|
|
16
23
|
const clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0))
|
|
17
24
|
const radius = (size - thickness) / 2
|
|
18
25
|
const circumference = 2 * Math.PI * radius
|
|
19
26
|
const dashOffset = circumference * (1 - clamped / 100)
|
|
20
27
|
|
|
28
|
+
const hasHighlight = Number.isFinite(highlightFrom as number) && Number.isFinite(highlightTo as number) && (typeof highlightFrom === 'number') && (typeof highlightTo === 'number') && highlightTo! > (highlightFrom as number)
|
|
29
|
+
const fromClamped = hasHighlight ? Math.max(0, Math.min(100, highlightFrom as number)) : 0
|
|
30
|
+
const toClamped = hasHighlight ? Math.max(0, Math.min(100, highlightTo as number)) : 0
|
|
31
|
+
const highlightLength = hasHighlight ? circumference * ((toClamped - fromClamped) / 100) : 0
|
|
32
|
+
const highlightOffset = hasHighlight ? circumference * (1 - toClamped / 100) : 0
|
|
33
|
+
|
|
21
34
|
return (
|
|
22
35
|
<div className={`relative inline-flex items-center justify-center ${className || ''}`} style={{ width: size, height: size }}>
|
|
23
36
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
|
|
24
37
|
<defs>
|
|
25
|
-
<linearGradient id=
|
|
38
|
+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
26
39
|
<stop offset="0%" stopColor="var(--icon-accent)" />
|
|
27
40
|
<stop offset="100%" stopColor="var(--icon-accent-hover)" />
|
|
28
41
|
</linearGradient>
|
|
@@ -41,7 +54,7 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
41
54
|
cy={size / 2}
|
|
42
55
|
r={radius}
|
|
43
56
|
fill="none"
|
|
44
|
-
stroke=
|
|
57
|
+
stroke={`url(#${gradientId})`}
|
|
45
58
|
strokeWidth={thickness}
|
|
46
59
|
strokeLinecap="round"
|
|
47
60
|
strokeDasharray={circumference}
|
|
@@ -49,6 +62,22 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
49
62
|
style={{ transition: 'stroke-dashoffset 800ms ease' }}
|
|
50
63
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
51
64
|
/>
|
|
65
|
+
{hasHighlight ? (
|
|
66
|
+
<circle
|
|
67
|
+
cx={size / 2}
|
|
68
|
+
cy={size / 2}
|
|
69
|
+
r={radius}
|
|
70
|
+
fill="none"
|
|
71
|
+
stroke="var(--icon-accent)"
|
|
72
|
+
strokeOpacity={0.6}
|
|
73
|
+
strokeWidth={thickness}
|
|
74
|
+
strokeLinecap="round"
|
|
75
|
+
strokeDasharray={`${highlightLength} ${circumference}`}
|
|
76
|
+
strokeDashoffset={highlightOffset}
|
|
77
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
78
|
+
className={highlightPulse ? 'animate-pulse' : ''}
|
|
79
|
+
/>
|
|
80
|
+
) : null}
|
|
52
81
|
{/* subtle pulsing halo */}
|
|
53
82
|
<circle
|
|
54
83
|
cx={size / 2}
|
|
@@ -76,3 +105,4 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
76
105
|
export default ProgressCircle
|
|
77
106
|
|
|
78
107
|
|
|
108
|
+
|