kyd-shared-badge 0.3.121 → 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.121",
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 } 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,482 +241,451 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
225
241
  // Progress UI is outsourced to ConnectProgress
226
242
 
227
243
  return (
228
- <>
229
-
230
- <AnimatePresence initial={false} mode="wait">
231
- {showDataHandling ? (
232
- <motion.div
233
- key="data-handling-card"
234
- className="rounded-xl border max-w-xl w-full"
235
- initial="initial" animate="animate" exit="exit" variants={fadeOnly}
236
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
237
- >
238
- <div className="sm:p-6 p-4">
239
- <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">
240
- <ArrowLeft className="w-4 h-4" />
241
- Back
242
- </button>
243
- <div className="text-center">
244
- <div className="flex justify-center mb-4">
245
- <InfoIcon className="w-10 h-10 text-[var(--text-main)]" />
246
- </div>
247
- <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
248
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
249
- 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
250
- <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>
251
- 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.
252
- </p>
253
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
254
- For other details, see our{' '}
255
- <Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
256
- Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
257
- </Link>.
258
- </p>
259
- </div>
260
- </div>
261
- </motion.div>
262
- ) : selectedProvider && selectedProvider.id !== 'githubapp' ? (
263
- <motion.div
264
- key="connect-card"
265
- initial="initial" animate="animate" exit="exit" variants={fadeOnly}
266
- className="rounded-xl border max-w-xl w-full"
267
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
268
- >
269
- <div className="sm:p-6 p-4">
270
- <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
271
- <ArrowLeft className="w-4 h-4" />
272
- Back
273
- </button>
274
- <div className="text-center">
275
- <div className="flex justify-center mb-4">
276
- <ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
277
- </div>
278
- <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
279
- {selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
280
- ? `Use Public ${selectedProvider.name} Profile`
281
- : `Connect ${selectedProvider.name}`}
282
- </h3>
283
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
284
- {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
285
- ? (selectedProvider.placeholder || 'Enter your public profile URL.')
286
- : `Authorize with ${selectedProvider.name} to connect your account.`}
287
- </p>
288
- </div>
289
-
290
- {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
291
- <motion.form
292
- onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
293
- className="mt-6 space-y-4"
294
- initial="initial" animate="animate" exit="exit" variants={cardVariants}
295
- >
296
- {selectedProvider.id === 'linkedin' && (
297
- <>
298
- <p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
299
- <Link
300
- href="https://www.linkedin.com/public-profile/settings"
301
- target="_blank"
302
- rel="noopener noreferrer"
303
- className="underline"
304
- style={{ color: 'var(--icon-accent)' }}
305
- >
306
- LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
307
- </Link>
308
- . This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
309
- </p>
310
- <p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
311
- LinkedIn data is not used to contribute to your score.
312
- </p>
313
- </>
314
-
315
- )}
316
- <div className="relative">
317
- <LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
318
- <Input
319
- type="url"
320
- value={linkUrl}
321
- onChange={(e) => setLinkUrl(e.target.value)}
322
- placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
323
- required
324
- className="w-full border bg-transparent p-2 pl-9"
325
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
326
- onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
327
- onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
328
- />
329
- </div>
330
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
331
- <Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
332
- {isSubmitting ? (
333
- <div className="flex items-center justify-center">
334
- <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
335
- Connecting...
336
- </div>
337
- ) : (
338
- 'Connect'
339
- )}
340
- </Button>
341
- </motion.div>
342
- </motion.form>
343
- ) : (
344
- <div className="mt-6">
345
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
346
- <Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
347
- <ExternalLink className="w-4 h-4 mr-2" />
348
- Connect with {selectedProvider.name}
349
- </Button>
350
- </motion.div>
351
- </div>
352
- )}
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
+ />
353
257
  </div>
354
- </motion.div>
355
- ) : selectedProvider && selectedProvider.id === 'githubapp' ? (
356
- (!showGithubManage && initialProviderId === 'githubapp') ? (
357
- <div
358
- key="github-card"
359
- className="rounded-xl border max-w-xl w-full"
360
- style={{
361
- backgroundColor: 'var(--content-card-background)',
362
- borderColor: 'var(--icon-button-secondary)',
363
- }}
364
- >
365
- <motion.div className="p-6 flex flex-col items-center" initial="initial" animate="animate" exit="exit" variants={fadeOnly}>
366
- <div className="w-full flex items-center gap-3 mb-2 justify-center">
367
- <ProviderIcon name="github" className="w-8 h-8 inline-block" />
368
- <span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
369
- </div>
370
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
371
- You&apos;ve successfully linked your GitHub account!
372
- <br />
373
- 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.
374
- <br /><br />
375
- <span className="text-[var(--text-main)] font-medium">
376
- Would you like to connect your private repositories?
377
- </span>
378
- <button
379
- type="button"
380
- onClick={() => setShowDataHandling(true)}
381
- className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
382
- >
383
- 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
384
269
  </button>
385
- </p>
386
- <div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
387
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
388
- <Button
389
- className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
390
- variant="destructive"
391
- onClick={() => {
392
- handleConnectBack();
393
- }}
394
- >
395
- No, don&#39;t connect
396
- </Button>
397
- </motion.div>
398
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
399
- <Button
400
- className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
401
- onClick={onGithubAppInstall}
402
- >
403
- <span className="flex items-center justify-center">
404
- <ExternalLink className="w-4 h-4 mr-2" />
405
- Yes, connect my private repos
406
- </span>
407
- </Button>
408
- </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>
409
287
  </div>
410
288
  </motion.div>
411
- </div>
412
- ) : (
413
- <motion.div
414
- key="github-manage"
415
- className="rounded-xl border max-w-xl w-full"
416
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
417
- variants={cardVariants}
418
- initial="initial"
419
- animate="animate"
420
- exit="exit"
421
- >
422
- <div className="sm:p-6 p-4">
423
- <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
424
- <ArrowLeft className="w-4 h-4" />
425
- Back
426
- </button>
427
- <div className="text-center">
428
- <div className="flex justify-center mb-4">
429
- <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>
430
315
  </div>
431
- <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>Manage GitHub Connections</h3>
432
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md">
433
- Connect or disconnect your GitHub OAuth account and optional GitHub App for private repositories.
434
- </p>
435
- </div>
436
316
 
437
- <div className="mt-6 space-y-4">
438
- <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
439
- <div className="flex items-center justify-between">
440
- <div>
441
- <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub OAuth</div>
442
- <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubConnected ? 'Connected' : 'Not connected'}</div>
443
- </div>
444
- <div className="flex items-center gap-2">
445
- {isGithubConnected ? (
446
- <Button
447
- onClick={() => onDisconnect('github')}
448
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
449
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
450
- variant="destructive"
451
- disabled={isDisconnecting === 'github'}
452
- >
453
- {isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
454
- <span>Disconnect</span>
455
- </Button>
456
- ) : (
457
- <Button
458
- onClick={() => onOAuth('github')}
459
- className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
460
- >
461
- <ExternalLink className="w-4 h-4 mr-2" />
462
- Connect
463
- </Button>
464
- )}
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
+ />
465
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>
466
378
  </div>
467
- </div>
468
-
469
- <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
470
- <div className="flex items-center justify-between">
471
- <div>
472
- <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 }}>
473
414
  <Button
474
- 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"
475
- onClick={() => setShowDataHandling(true)}
476
- 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
+ }}
477
420
  >
478
- How is my data handled?
421
+ No, don&#39;t connect
479
422
  </Button>
480
- </div>
481
- <div className="flex items-center gap-2">
482
- {isGithubAppInstalled ? (
483
- <Button
484
- onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
485
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
486
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
487
- variant="destructive"
488
- >
489
- <Unlink className="size-3 sm:size-4" />
490
- <span>Uninstall</span>
491
- </Button>
492
- ) : (
493
- <Button
494
- onClick={onGithubAppInstall}
495
- className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
496
- >
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">
497
430
  <ExternalLink className="w-4 h-4 mr-2" />
498
- Install
499
- </Button>
500
- )}
501
- </div>
431
+ Yes, connect my private repos
432
+ </span>
433
+ </Button>
434
+ </motion.div>
502
435
  </div>
503
- </div>
436
+ </motion.div>
504
437
  </div>
505
- </div>
506
- </motion.div>
507
- )
508
- ) : (
509
- <Card className="border-[var(--icon-button-secondary)] pt-2" style={{ backgroundColor: 'var(--content-card-background)'}}>
510
- <AnimatePresence mode="wait">
511
- <motion.div key="platform-list-shared" variants={cardVariants} initial="initial" animate="animate" exit="exit" transition={{ duration: 0.3 }}>
512
- <CardHeader className="pb-4">
513
- {handleBackButton && (
514
- <button onClick={() => handleBackButton()} className="flex items-center gap-2 text-sm mb-4 text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors">
515
- <ArrowLeft className="w-4 h-4" /> Back
516
- </button>
517
- )}
518
- <CardTitle className="sm:text-xl text-base font-semibold text-[var(--text-main)] mb-2">{headerTitle}</CardTitle>
519
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed">{headerDescription}</p>
520
- </CardHeader>
521
- <CardContent className="space-y-2">
522
- {(() => {
523
- const oauthList = list.filter(p => p.connectionType === 'oauth');
524
- const urlList = list.filter(p => (p.connectionType || 'url') === 'url' || p.connectionType === 'link');
525
-
526
- const Row = (provider: typeof list[number]) => {
527
- const providerId = provider.id;
528
- const isRequired = requiredProviders?.includes(providerId);
529
- const isConnected = connectedIds.has(providerId.toLowerCase());
530
- const needsReconnect = reconnectIds.has(providerId.toLowerCase());
531
- const isOauth = provider.connectionType === 'oauth'
532
- const connectedUrl = connected.find(c => c.name.toLowerCase() === providerId.toLowerCase())?.url;
533
- const betaFlag = () => (
534
- <span
535
- 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"
536
- style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
537
- >
538
- Beta
539
- </span>
540
- );
541
-
542
- const requiredFlag = () => (
543
- <TooltipProvider>
544
- <Tooltip>
545
- <TooltipTrigger>
546
- <div className="flex items-center justify-center size-4 sm:size-5 rounded-full bg-[var(--icon-accent)] cursor-help">
547
- <span className="text-white text-xs font-bold -translate-y-px select-none">!</span>
548
- </div>
549
- </TooltipTrigger>
550
- <TooltipContent>
551
- <p>Required by <span className="font-semibold">{companyName || 'your recruiter'}</span></p>
552
- </TooltipContent>
553
- </Tooltip>
554
- </TooltipProvider>
555
- );
556
-
557
- return (
558
- <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">
559
- <div className="flex items-center gap-3">
560
-
561
- {isConnected && connectedUrl ? (
562
- <Link href={connectedUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:underline">
563
- <ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
564
- <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
565
- {isRequired && !isConnected ? (requiredFlag()) : null}
566
- </Link>
567
- ) : (
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">
568
489
  <div className="flex items-center gap-3">
569
- <ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
570
- <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
571
- {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}
572
505
  </div>
573
- )}
574
- {provider.beta ? betaFlag() : null}
575
- </div>
576
-
577
- {providerId.toLowerCase() === 'github' ? (
578
- <div className="flex items-center gap-2">
579
- {(!isConnected && !isGithubAppInstalled) && (
580
- <Button
581
- onClick={() => setSelectedProviderIdAndCallback(providerId)}
582
- 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"
583
- >
584
- <Link2 className="size-3 sm:size-4" />
585
- <span className="sm:text-base text-sm">Connect</span>
586
- </Button>
587
- )}
588
- {(isConnected && !isGithubAppInstalled) && (
589
- <Button
590
- onClick={() => { setShowGithubManage(true); setSelectedProviderIdAndCallback('githubapp'); }}
591
- 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"
592
- >
593
- <Settings className="size-3 sm:size-4" />
594
- <span className="sm:text-base text-sm">Manage</span>
595
- </Button>
596
- )}
597
- {(isConnected && isGithubAppInstalled) && (
598
- <div
599
- className="relative flex items-center group"
600
- onClick={() => { setShowGithubManage(true); setSelectedProviderIdAndCallback('githubapp'); }}
601
- style={{ cursor: 'pointer' }}
602
- >
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">
603
574
  <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
604
575
  <CheckCircle className="size-3 sm:size-4" />
605
576
  <span className="text-sm font-medium">Connected</span>
606
577
  </div>
607
578
  <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
608
- <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
+ >
609
585
  <Unlink className="size-3 sm:size-4" />
610
586
  <span>Disconnect</span>
611
- </div>
587
+ </button>
612
588
  </div>
613
589
  </div>
614
- )}
615
- {isDisconnecting === providerId && (
616
- <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>
617
625
  )}
618
626
  </div>
619
- ) : isConnected && isDisconnecting !== providerId ? (
620
- <div className="relative flex items-center">
621
- <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
622
- <CheckCircle className="size-3 sm:size-4" />
623
- <span className="text-sm font-medium">Connected</span>
624
- </div>
625
- <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
626
- <button
627
- onClick={() => onDisconnect(providerId)}
628
- className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
629
- >
630
- <Unlink className="size-3 sm:size-4" />
631
- <span>Disconnect</span>
632
- </button>
633
- </div>
634
- </div>
635
- ) : isDisconnecting === providerId ? (
636
- <div className="relative flex items-center">
637
- <Spinner />
638
- </div>
639
- ) : (
640
- <div className="flex items-center gap-2">
641
- <>
642
- <Button
643
- onClick={() => setSelectedProviderIdAndCallback(providerId)}
644
- 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"
645
- >
646
- {isOauth ? (
647
- <Link2 className="size-3 sm:size-4" />
648
- ) : (
649
- <LinkIcon className="size-3 sm:size-4" />
650
- )}
651
- <span className="sm:text-base text-sm">Connect</span>
652
- </Button>
653
- {needsReconnect && !isRequired && (
654
- <Button
655
- onClick={() => onDisconnect(providerId)}
656
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
657
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
658
- variant="destructive"
659
- >
660
- <Unlink className="size-3 sm:size-4" />
661
- <span>Remove</span>
662
- </Button>
663
- )}
664
- </>
665
- </div>
666
- )}
667
- </div>
668
- );
669
- };
627
+ );
628
+ };
670
629
 
671
- return (
672
- <>
673
- {oauthList.length ? (
630
+ return (
674
631
  <>
675
- <div className="flex items-center my-2">
676
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
677
- <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>OAuth</span>
678
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
679
- </div>
680
- {oauthList.map(p => Row(p))}
681
- </>
682
- ) : 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}
683
642
 
684
- {urlList.length ? (
685
- <>
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)'}}>Public Urls</span>
689
- <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
690
- </div>
691
- {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}
692
653
  </>
693
- ) : null}
694
- </>
695
- );
696
- })()}
697
- </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>
698
672
  </motion.div>
699
- </AnimatePresence>
700
- </Card>
701
- )}
702
- </AnimatePresence>
703
- </>
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>
704
689
  );
705
690
  }
706
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
@@ -72,18 +98,45 @@ export function ConnectProgress(props: ConnectProgressProps) {
72
98
  }
73
99
  }, [progressPercent, hasGithub])
74
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
+
75
121
  const content = (
76
122
  <Card className={`border-[var(--icon-button-secondary)] ${className || ''}`} style={{ backgroundColor: 'var(--content-card-background)'}}>
77
123
  <CardContent className="py-3 px-3">
78
124
  <div className="flex items-center gap-3">
79
- <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
+ />
80
133
  <div className="min-w-0 max-w-xs">
81
134
  <div className="text-base font-semibold truncate" style={{ color: 'var(--text-main)'}}>{progressCopy.headline}</div>
82
135
  <div className="text-sm mt-0.5" style={{ color: 'var(--text-secondary)'}}>
83
136
  {progressCopy.body}
84
137
  </div>
85
138
  <div className="text-sm mt-1" style={{ color: 'var(--text-secondary)'}}>
86
- Weighted score • {progressPercent}%
139
+ Weighted score • {valueToShow}%
87
140
  </div>
88
141
  </div>
89
142
  </div>
@@ -8,10 +8,14 @@ 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
15
19
 
16
20
  const rawId = React.useId()
17
21
  const gradientId = React.useMemo(() => `kyd-pc-${String(rawId).replace(/:/g, '')}` , [rawId])
@@ -21,6 +25,12 @@ export function ProgressCircle(props: ProgressCircleProps) {
21
25
  const circumference = 2 * Math.PI * radius
22
26
  const dashOffset = circumference * (1 - clamped / 100)
23
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
+
24
34
  return (
25
35
  <div className={`relative inline-flex items-center justify-center ${className || ''}`} style={{ width: size, height: size }}>
26
36
  <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
@@ -52,6 +62,22 @@ export function ProgressCircle(props: ProgressCircleProps) {
52
62
  style={{ transition: 'stroke-dashoffset 800ms ease' }}
53
63
  transform={`rotate(-90 ${size / 2} ${size / 2})`}
54
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}
55
81
  {/* subtle pulsing halo */}
56
82
  <circle
57
83
  cx={size / 2}