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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.120",
3
+ "version": "0.3.122",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -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, Settings, Shield, InfoIcon } from 'lucide-react';
7
- import { AnimatePresence, motion } from 'framer-motion';
8
- import { Button, Input, Spinner, Card, CardHeader, CardContent, CardFooter, CardTitle, ConnectProgress } from '../ui';
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
- {/* Mobile: relative summary above content */}
230
- {providers?.length ? (
231
- <div className="sm:hidden px-4 mb-3">
232
- <ConnectProgress layout="inline" providers={providers} connectedIds={connectedIds} />
233
- </div>
234
- ) : null}
235
-
236
- {/* Desktop: fixed top-right summary */}
237
- {providers?.length ? (
238
- <ConnectProgress providers={providers} connectedIds={connectedIds} />
239
- ) : null}
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
- </motion.div>
273
- ) : selectedProvider && selectedProvider.id !== 'githubapp' ? (
274
- <motion.div
275
- key="connect-card"
276
- initial="initial" animate="animate" exit="exit" variants={fadeOnly}
277
- className="rounded-xl border max-w-xl w-full"
278
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
279
- >
280
- <div className="sm:p-6 p-4">
281
- <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
282
- <ArrowLeft className="w-4 h-4" />
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&apos;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&apos;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
- </p>
397
- <div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
398
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
399
- <Button
400
- className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
401
- variant="destructive"
402
- onClick={() => {
403
- handleConnectBack();
404
- }}
405
- >
406
- No, don&#39;t connect
407
- </Button>
408
- </motion.div>
409
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
410
- <Button
411
- className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
412
- onClick={onGithubAppInstall}
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
- </div>
423
- ) : (
424
- <motion.div
425
- key="github-manage"
426
- className="rounded-xl border max-w-xl w-full"
427
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
428
- variants={cardVariants}
429
- initial="initial"
430
- animate="animate"
431
- exit="exit"
432
- >
433
- <div className="sm:p-6 p-4">
434
- <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
435
- <ArrowLeft className="w-4 h-4" />
436
- Back
437
- </button>
438
- <div className="text-center">
439
- <div className="flex justify-center mb-4">
440
- <ProviderIcon name="github" className="w-10 h-10" />
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
- <div className="mt-6 space-y-4">
449
- <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
450
- <div className="flex items-center justify-between">
451
- <div>
452
- <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub OAuth</div>
453
- <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubConnected ? 'Connected' : 'Not connected'}</div>
454
- </div>
455
- <div className="flex items-center gap-2">
456
- {isGithubConnected ? (
457
- <Button
458
- onClick={() => onDisconnect('github')}
459
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
460
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
461
- variant="destructive"
462
- disabled={isDisconnecting === 'github'}
463
- >
464
- {isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
465
- <span>Disconnect</span>
466
- </Button>
467
- ) : (
468
- <Button
469
- onClick={() => onOAuth('github')}
470
- className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
471
- >
472
- <ExternalLink className="w-4 h-4 mr-2" />
473
- Connect
474
- </Button>
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
- </div>
479
-
480
- <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
481
- <div className="flex items-center justify-between">
482
- <div>
483
- <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub App (Private Repos)</div>
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&apos;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&apos;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="inline-flex items-center text-xs font-medium text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)] transition-colors underline px-0 py-0 h-auto"
486
- onClick={() => setShowDataHandling(true)}
487
- variant="link"
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
- How is my data handled?
421
+ No, don&#39;t connect
490
422
  </Button>
491
- </div>
492
- <div className="flex items-center gap-2">
493
- {isGithubAppInstalled ? (
494
- <Button
495
- onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
496
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
497
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
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
- Install
510
- </Button>
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
- </div>
517
- </motion.div>
518
- )
519
- ) : (
520
- <Card className="border-[var(--icon-button-secondary)] pt-2" style={{ backgroundColor: 'var(--content-card-background)'}}>
521
- <AnimatePresence mode="wait">
522
- <motion.div key="platform-list-shared" variants={cardVariants} initial="initial" animate="animate" exit="exit" transition={{ duration: 0.3 }}>
523
- <CardHeader className="pb-4">
524
- {handleBackButton && (
525
- <button onClick={() => handleBackButton()} className="flex items-center gap-2 text-sm mb-4 text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors">
526
- <ArrowLeft className="w-4 h-4" /> Back
527
- </button>
528
- )}
529
- <CardTitle className="sm:text-xl text-base font-semibold text-[var(--text-main)] mb-2">{headerTitle}</CardTitle>
530
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed">{headerDescription}</p>
531
- </CardHeader>
532
- <CardContent className="space-y-2">
533
- {(() => {
534
- const oauthList = list.filter(p => p.connectionType === 'oauth');
535
- const urlList = list.filter(p => (p.connectionType || 'url') === 'url' || p.connectionType === 'link');
536
-
537
- const Row = (provider: typeof list[number]) => {
538
- const providerId = provider.id;
539
- const isRequired = requiredProviders?.includes(providerId);
540
- const isConnected = connectedIds.has(providerId.toLowerCase());
541
- const needsReconnect = reconnectIds.has(providerId.toLowerCase());
542
- const isOauth = provider.connectionType === 'oauth'
543
- const connectedUrl = connected.find(c => c.name.toLowerCase() === providerId.toLowerCase())?.url;
544
- const betaFlag = () => (
545
- <span
546
- 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"
547
- style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
548
- >
549
- Beta
550
- </span>
551
- );
552
-
553
- const requiredFlag = () => (
554
- <TooltipProvider>
555
- <Tooltip>
556
- <TooltipTrigger>
557
- <div className="flex items-center justify-center size-4 sm:size-5 rounded-full bg-[var(--icon-accent)] cursor-help">
558
- <span className="text-white text-xs font-bold -translate-y-px select-none">!</span>
559
- </div>
560
- </TooltipTrigger>
561
- <TooltipContent>
562
- <p>Required by <span className="font-semibold">{companyName || 'your recruiter'}</span></p>
563
- </TooltipContent>
564
- </Tooltip>
565
- </TooltipProvider>
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
- <ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
581
- <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
582
- {isRequired && !isConnected ? (requiredFlag()) : null}
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
- {provider.beta ? betaFlag() : null}
586
- </div>
587
-
588
- {providerId.toLowerCase() === 'github' ? (
589
- <div className="flex items-center gap-2">
590
- {(!isConnected && !isGithubAppInstalled) && (
591
- <Button
592
- onClick={() => setSelectedProviderIdAndCallback(providerId)}
593
- 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"
594
- >
595
- <Link2 className="size-3 sm:size-4" />
596
- <span className="sm:text-base text-sm">Connect</span>
597
- </Button>
598
- )}
599
- {(isConnected && !isGithubAppInstalled) && (
600
- <Button
601
- onClick={() => { setShowGithubManage(true); setSelectedProviderIdAndCallback('githubapp'); }}
602
- 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"
603
- >
604
- <Settings className="size-3 sm:size-4" />
605
- <span className="sm:text-base text-sm">Manage</span>
606
- </Button>
607
- )}
608
- {(isConnected && isGithubAppInstalled) && (
609
- <div
610
- className="relative flex items-center group"
611
- onClick={() => { setShowGithubManage(true); setSelectedProviderIdAndCallback('githubapp'); }}
612
- style={{ cursor: 'pointer' }}
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
- <div className="flex items-center gap-1.5 text-red-600 text-sm hover:underline">
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
- </div>
587
+ </button>
623
588
  </div>
624
589
  </div>
625
- )}
626
- {isDisconnecting === providerId && (
627
- <Spinner />
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
- ) : isConnected && isDisconnecting !== providerId ? (
631
- <div className="relative flex items-center">
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
- return (
683
- <>
684
- {oauthList.length ? (
630
+ return (
685
631
  <>
686
- <div className="flex items-center my-2">
687
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
688
- <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>OAuth</span>
689
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
690
- </div>
691
- {oauthList.map(p => Row(p))}
692
- </>
693
- ) : null}
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
- {urlList.length ? (
696
- <>
697
- <div className="flex items-center my-2">
698
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
699
- <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>Public Urls</span>
700
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
701
- </div>
702
- {urlList.map(p => Row(p))}
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
- ) : null}
705
- </>
706
- );
707
- })()}
708
- </CardContent>
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
- </AnimatePresence>
711
- </Card>
712
- )}
713
- </AnimatePresence>
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
 
@@ -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,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 progressPercent = useMemo(() => {
25
- const githubWeight = hasGithub ? 65 : 0
26
- const linkedinWeight = hasLinkedIn ? 15 : 0
27
- const othersWeight = Math.min(otherConnectedCount, 2) * 10
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
- }, [hasGithub, hasLinkedIn, otherConnectedCount])
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 value={progressPercent} size={64} thickness={6} />
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-sm font-semibold truncate" style={{ color: 'var(--text-main)'}}>{progressCopy.headline}</div>
76
- <div className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)'}}>
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-[10px] mt-1" style={{ color: 'var(--text-secondary)'}}>
80
- Weighted score • {progressPercent}%
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="kyd-progress-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
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="url(#kyd-progress-gradient)"
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
+