Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,12 @@ Example: Adding a video to the jobs page

## Pull Requests

Keep PR descriptions concise and to the point. Reviewers should not be exhausted by lengthy explanations.
Keep PR descriptions concise and to the point. Reviewers should not be exhausted by lengthy explanations.

## Code Review Guidelines

When reviewing code (or writing code that will be reviewed):
- **Delete dead code** - Remove unused components, functions, exports, and files. Don't leave code "for later"
- **Avoid confusing naming** - Don't create multiple components with the same name in different locations (e.g., two `AboutMe` components)
- **Remove unused exports** - If a function/constant is only used internally, don't export it
- **Clean up duplicates** - If the same interface/type is defined in multiple places, consolidate to one location and import
29 changes: 2 additions & 27 deletions packages/shared/src/components/auth/RegistrationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,7 @@ import { formToJson } from '../../lib/form';
import { Button, ButtonVariant, ButtonSize } from '../buttons/Button';
import { PasswordField } from '../fields/PasswordField';
import { TextField } from '../fields/TextField';
import {
MailIcon,
UserIcon,
VIcon,
AtIcon,
TwitterIcon,
ArrowIcon,
} from '../icons';
import { MailIcon, UserIcon, VIcon, AtIcon, ArrowIcon } from '../icons';
import type { CloseModalFunc } from '../modals/common';
import TokenInput from './TokenField';
import AuthForm from './AuthForm';
Expand Down Expand Up @@ -87,7 +80,6 @@ const RegistrationForm = ({
useState<boolean>(false);
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
const [name, setName] = useState('');
const isAuthorOnboarding = trigger === AuthTriggers.Author;
const isRecruiterOnboarding = trigger === AuthTriggers.RecruiterSelfServe;
const {
username,
Expand Down Expand Up @@ -210,21 +202,16 @@ const RegistrationForm = ({
{},
{
username: values['traits.username'],
twitter: values['traits.twitter'],
},
);

if (error.username || error.twitter) {
if (error.username) {
const updatedHints = { ...hints };

if (error.username) {
updatedHints['traits.username'] = error.username;
}

if (error.twitter) {
updatedHints['traits.twitter'] = error.twitter;
}

onUpdateHints(updatedHints);
return;
}
Expand Down Expand Up @@ -397,18 +384,6 @@ const RegistrationForm = ({
}
rightIcon={usernameIcon}
/>
{isAuthorOnboarding && (
<TextField
saveHintSpace
className={{ container: 'w-full' }}
leftIcon={<TwitterIcon aria-hidden role="presentation" />}
name="traits.twitter"
inputId="traits.twitter"
label="X"
type="text"
required
/>
)}
{!isRecruiterOnboarding && (
<ExperienceLevelDropdown
className={{ container: 'w-full' }}
Expand Down
31 changes: 2 additions & 29 deletions packages/shared/src/components/auth/SocialRegistrationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import type {
AuthTriggersType,
SocialRegistrationParameters,
} from '../../lib/auth';
import { AuthEventNames, AuthTriggers } from '../../lib/auth';
import { AuthEventNames } from '../../lib/auth';
import { formToJson } from '../../lib/form';
import { Button, ButtonVariant } from '../buttons/Button';
import ImageInput from '../fields/ImageInput';
import { TextField } from '../fields/TextField';
import { MailIcon, UserIcon, LockIcon, AtIcon, TwitterIcon } from '../icons';
import { MailIcon, UserIcon, LockIcon, AtIcon } from '../icons';
import AuthHeader from './AuthHeader';
import type { AuthFormProps } from './common';
import { providerMap } from './common';
Expand Down Expand Up @@ -53,7 +53,6 @@ export const SocialRegistrationForm = ({
formRef,
title = 'Sign up',
hints,
trigger,
onUpdateHints,
onSignup,
isLoading,
Expand All @@ -63,10 +62,8 @@ export const SocialRegistrationForm = ({
const { user } = useContext(AuthContext);
const [nameHint, setNameHint] = useState<string>(null);
const [usernameHint, setUsernameHint] = useState<string>(null);
const [twitterHint, setTwitterHint] = useState<string>(null);
const [experienceLevelHint, setExperienceLevelHint] = useState<string>(null);
const [name, setName] = useState(user?.name);
const isAuthorOnboarding = trigger === AuthTriggers.Author;
const {
username,
setUsername,
Expand Down Expand Up @@ -127,16 +124,10 @@ export const SocialRegistrationForm = ({
return;
}

if (isAuthorOnboarding && !values.twitter) {
logError('Twitter not provider');
setTwitterHint('Please add your twitter handle');
}

logEvent({
event_name: AuthEventNames.SubmitSignupFormExtra,
extra: JSON.stringify({
username: values?.username,
twitter: values?.twitter,
acceptedMarketing: !values?.optOutMarketing,
experienceLevel: values?.experienceLevel,
language: values?.language,
Expand Down Expand Up @@ -238,24 +229,6 @@ export const SocialRegistrationForm = ({
}
rightIcon={isLoadingUsername ? <Loader /> : null}
/>
{isAuthorOnboarding && (
<TextField
saveHintSpace
className={{ container: 'w-full' }}
leftIcon={<TwitterIcon />}
name="twitter"
inputId="twitter"
label="X"
type="text"
valid={!twitterHint}
hint={twitterHint}
valueChanged={() => {
if (twitterHint) {
setTwitterHint('');
}
}}
/>
)}
<ExperienceLevelDropdown
className={{ container: 'w-full' }}
name="experienceLevel"
Expand Down
221 changes: 221 additions & 0 deletions packages/shared/src/components/profile/SocialLinksInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import React, { useCallback, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { TextField } from '../fields/TextField';
import { Typography, TypographyType } from '../typography/Typography';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { PlusIcon, MiniCloseIcon, VIcon } from '../icons';
import { IconSize } from '../Icon';
import type { UserSocialLink } from '../../lib/user';
import type { SocialLinkDisplay } from '../../lib/socialLink';
import {
detectUserPlatform,
getPlatformIcon,
getPlatformLabel,
PLATFORM_LABELS,
} from '../../lib/socialLink';
import { useToastNotification } from '../../hooks/useToastNotification';

export interface SocialLinksInputProps {
name: string;
label?: string;
hint?: string;
}

/**
* Get display info for a social link
*/
const getSocialLinkDisplay = (link: UserSocialLink): SocialLinkDisplay => {
return {
id: link.platform,
url: link.url,
platform: link.platform,
icon: getPlatformIcon(link.platform, IconSize.Small),
label: getPlatformLabel(link.platform),
};
};

export function SocialLinksInput({
name,
label = 'Links',
hint = 'Connect your profiles across the web',
}: SocialLinksInputProps): ReactElement {
const { control } = useFormContext();
const {
field: { value = [], onChange },
fieldState: { error },
} = useController({
name,
control,
defaultValue: [],
});

const [url, setUrl] = useState('');
const { displayToast } = useToastNotification();

const links: UserSocialLink[] = useMemo(() => value || [], [value]);

// Detect platform as user types
const detectedPlatform = detectUserPlatform(url);
const detectedLabel = detectedPlatform
? PLATFORM_LABELS[detectedPlatform]
: null;

const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value);
};

const handleAdd = useCallback(() => {
const trimmedUrl = url.trim();
if (!trimmedUrl) {
return;
}

// Basic URL validation
try {
const parsedUrl = new URL(
trimmedUrl.startsWith('http') ? trimmedUrl : `https://${trimmedUrl}`,
);
// Normalize URL by removing trailing slash for consistency
const normalizedUrl = parsedUrl.href.replace(/\/$/, '');

// Check if URL already exists
if (
links.some(
(link) =>
link.url.toLowerCase().replace(/\/$/, '') ===
normalizedUrl.toLowerCase(),
)
) {
displayToast('This link has already been added');
return;
}

const newLink: UserSocialLink = {
url: normalizedUrl,
platform: detectedPlatform || 'other',
};

onChange([...links, newLink]);
setUrl('');
} catch {
displayToast('Please enter a valid URL');
}
}, [url, detectedPlatform, links, onChange, displayToast]);

const handleRemove = useCallback(
(index: number) => {
const newLinks = [...links];
newLinks.splice(index, 1);
onChange(newLinks);
},
[links, onChange],
);

const displayLinks = useMemo(() => links.map(getSocialLinkDisplay), [links]);

return (
<div className="flex flex-col gap-4">
{/* Header */}
<div>
<Typography type={TypographyType.Body} bold>
{label}
</Typography>
<Typography
type={TypographyType.Callout}
className="text-text-secondary"
>
{hint}
</Typography>
</div>

{/* URL input */}
<TextField
type="url"
inputId="socialLinkUrl"
label="Add link"
placeholder="Paste a URL (e.g., github.com/username)"
value={url}
onChange={handleUrlChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
}}
fieldType="secondary"
actionButton={
<Button
type="button"
variant={ButtonVariant.Secondary}
size={ButtonSize.XSmall}
icon={<PlusIcon />}
onClick={handleAdd}
disabled={!url.trim()}
>
Add
</Button>
}
/>

{/* Detection feedback */}
{detectedLabel && (
<div className="bg-status-success/10 flex items-center gap-2 rounded-10 px-3 py-2">
<VIcon className="text-status-success" size={IconSize.Small} />
<Typography type={TypographyType.Footnote}>
{detectedLabel} detected
</Typography>
</div>
)}

{/* Link list */}
{displayLinks.length > 0 && (
<div className="flex flex-col gap-2">
{displayLinks.map((link, index) => (
<div
key={link.url}
className="flex items-center gap-3 rounded-12 border border-border-subtlest-tertiary bg-background-subtle p-3"
>
{/* Platform icon */}
<div className="flex-shrink-0 text-text-secondary">
{link.icon}
</div>

{/* Content */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography type={TypographyType.Callout} bold>
{link.label}
</Typography>
<Typography
type={TypographyType.Caption1}
className="truncate text-text-tertiary"
>
{link.url}
</Typography>
</div>

{/* Remove button */}
<button
type="button"
onClick={() => handleRemove(index)}
className="flex-shrink-0 rounded-8 p-1 text-text-quaternary transition-colors hover:bg-surface-float hover:text-text-primary"
aria-label="Remove link"
>
<MiniCloseIcon size={IconSize.Medium} />
</button>
</div>
))}
</div>
)}

{error?.message && (
<Typography
type={TypographyType.Footnote}
className="text-status-error"
>
{error.message}
</Typography>
)}
</div>
);
}
8 changes: 8 additions & 0 deletions packages/shared/src/features/organizations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export enum SocialMediaType {
Medium = 'medium',
DevTo = 'devto',
StackOverflow = 'stackoverflow',
// User profile platforms
Threads = 'threads',
Bluesky = 'bluesky',
Mastodon = 'mastodon',
Roadmap = 'roadmap',
Codepen = 'codepen',
Reddit = 'reddit',
Hashnode = 'hashnode',
}

export type OrganizationMember = {
Expand Down
Loading