Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
098c1c9
feat(referral): redesign invite settings page around 3-invites Plus p…
tsahimatsliah Jul 22, 2026
e258dc5
refactor(referral): flatten invite page into compact, consistent sect…
tsahimatsliah Jul 22, 2026
878ff15
style(referral): match giveback card to the founding-reward treatment
tsahimatsliah Jul 22, 2026
479dbc0
refactor(referral): simplify invite copy and progress, drop how-it-works
tsahimatsliah Jul 23, 2026
fd5a31b
fix(referral): left-align the referrals list with the rest of the page
tsahimatsliah Jul 23, 2026
6a013c2
feat(referral): add invite friends Storybook page with all states
tsahimatsliah Jul 23, 2026
b29f36f
refactor(referral): gate the Plus reward behind a flag and clean up
tsahimatsliah Jul 23, 2026
2093aa2
fix(giveback): keep the invite card charm visible in light mode
tsahimatsliah Jul 23, 2026
2814d6c
feat(referral): celebrate the unlocked Plus month and notify on it
tsahimatsliah Jul 23, 2026
294ede2
fix(giveback): cut the charm's black out instead of plating it
tsahimatsliah Jul 23, 2026
1d72dc5
chore(referral): drop em dashes from the Invite friends story names
tsahimatsliah Jul 23, 2026
874813c
style(referral): tighten the unlocked reward card
tsahimatsliah Jul 23, 2026
cea411d
style(referral): flatten the unlocked reward card
tsahimatsliah Jul 23, 2026
f0ce21d
Merge branch 'main' into claude/refer-friend-settings-redesign-a23ebe
tsahimatsliah Jul 23, 2026
46b20f7
feat(referral): split copy/share control, always-on Plus promo
tsahimatsliah Jul 23, 2026
2cc5a3e
chore(referral): drop the launch config and the dead copy log props
tsahimatsliah Jul 23, 2026
41ae06d
refactor(share): align the split control with the share branch
tsahimatsliah Jul 23, 2026
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
9 changes: 9 additions & 0 deletions packages/shared/src/components/notifications/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
AddUserIcon,
SquadIcon,
MegaphoneIcon,
DevPlusIcon,
} from '../icons';
import type { NotificationPromptSource } from '../../lib/log';
import { BookmarkReminderIcon } from '../icons/Bookmark/Reminder';
Expand Down Expand Up @@ -98,6 +99,8 @@ export enum NotificationType {
WarmIntro = 'warm_intro',
ExperienceCompanyEnriched = 'experience_company_enriched',
LiveRoomStarted = 'live_room_started',
ReferralFriendJoined = 'referral_friend_joined',
ReferralPlusUnlocked = 'referral_plus_unlocked',
}

export enum NotificationIconType {
Expand All @@ -118,6 +121,7 @@ export enum NotificationIconType {
Core = 'Core',
Analytics = 'Analytics',
Opportunity = 'Opportunity',
DevPlus = 'DevPlus',
}

export const notificationIcon: Record<
Expand All @@ -141,6 +145,7 @@ export const notificationIcon: Record<
[NotificationIconType.Core]: CoreIcon,
[NotificationIconType.Analytics]: AnalyticsIcon,
[NotificationIconType.Opportunity]: JobIcon,
[NotificationIconType.DevPlus]: DevPlusIcon,
};

export const notificationIconAsPrimary: NotificationIconType[] = [
Expand All @@ -166,6 +171,7 @@ export const notificationIconTypeTheme: Record<NotificationIconType, string> = {
[NotificationIconType.Core]: '',
[NotificationIconType.Analytics]: 'text-brand-default',
[NotificationIconType.Opportunity]: 'text-black',
[NotificationIconType.DevPlus]: 'text-action-plus-default',
};

export const notificationIconStyle: Record<
Expand All @@ -189,6 +195,7 @@ export const notificationIconStyle: Record<
[NotificationIconType.Core]: null,
[NotificationIconType.Analytics]: null,
[NotificationIconType.Opportunity]: { background: briefButtonBg },
[NotificationIconType.DevPlus]: null,
};

export const notificationTypeTheme: Partial<Record<NotificationType, string>> =
Expand Down Expand Up @@ -430,6 +437,8 @@ export const notificationCategoryToTypes: Record<
NotificationType.WarmIntro,
NotificationType.ExperienceCompanyEnriched,
NotificationType.LiveRoomStarted,
NotificationType.ReferralFriendJoined,
NotificationType.ReferralPlusUnlocked,
],
};

Expand Down
31 changes: 22 additions & 9 deletions packages/shared/src/components/referral/InviteLinkInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactElement } from 'react';
import React from 'react';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import type { TextFieldProps } from '../fields/TextField';
import { TextField } from '../fields/TextField';
import { useCopyLink } from '../../hooks/useCopy';
import { useLogContext } from '../../contexts/LogContext';
Expand All @@ -17,7 +18,13 @@ interface InviteLinkInputProps {
text?: Text;
onCopy?: () => void;
className?: FieldClassName;
logProps: LogEvent;
// Logged on copy. Only the built-in button copies, so surfaces that pass
// their own `actionButton` log from there instead.
logProps?: LogEvent;
// Replaces the built-in copy button, for surfaces that need a richer control
// (e.g. the split copy/share button). Optional so every other consumer keeps
// its original button and copy handling.
actionButton?: TextFieldProps['actionButton'];
}

export function InviteLinkInput({
Expand All @@ -26,12 +33,16 @@ export function InviteLinkInput({
onCopy,
className,
logProps,
actionButton,
}: InviteLinkInputProps): ReactElement {
const [copied, onCopyLink] = useCopyLink(() => link);
const { logEvent } = useLogContext();
const onCopyClick = () => {
onCopyLink();
logEvent(logProps);

if (logProps) {
logEvent(logProps);
}

if (onCopy) {
onCopy();
Expand All @@ -56,13 +67,15 @@ export function InviteLinkInput({
value={link}
fieldType="tertiary"
actionButton={
<Button
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
onClick={onCopyClick}
>
{renderText()}
</Button>
actionButton ?? (
<Button
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
onClick={onCopyClick}
>
{renderText()}
</Button>
)
}
readOnly
/>
Expand Down
177 changes: 177 additions & 0 deletions packages/shared/src/components/referral/InviteRewardProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import { format } from 'date-fns';
import {
Typography,
TypographyColor,
TypographyType,
} from '../typography/Typography';
import { ProfileImageSize, ProfilePicture } from '../ProfilePicture';
import { AddUserIcon, DevPlusIcon, VIcon } from '../icons';
import { IconSize } from '../Icon';
import type { UserShortProfile } from '../../lib/user';

export const INVITE_GOAL = 3;

export interface InviteRewardPeriod {
startsAt: Date;
endsAt: Date;
}

interface InviteRewardProgressProps {
// The raw number of developers who joined. Values outside 0..INVITE_GOAL are
// clamped here, so callers can pass the referral count as-is.
joinedCount: number;
referredUsers: UserShortProfile[];
// When the free month runs. Optional: the referral list can still be loading
// when the count already says the goal is met, and the unlocked state reads
// fine without it.
rewardPeriod?: InviteRewardPeriod;
className?: string;
}

const formatRewardDate = (date: Date): string => format(date, 'd MMM yyyy');

// The row of avatars/slots, one per friend needed. Filled slots use the same
// rounded-rect radius as ProfilePicture so filled and empty share a silhouette.
const InviteSlots = ({
joined,
referredUsers,
// The unlocked card keeps the row as a footnote under the reward, so it runs
// one size down from the standalone tracker.
isCompact = false,
}: {
joined: number;
referredUsers: UserShortProfile[];
isCompact?: boolean;
}): ReactElement => (
// The caption states the progress and every referred developer is named in
// the list further down the page, so the slot row is presentational.
<div
aria-hidden
className={classNames(
'flex flex-wrap items-center',
isCompact ? 'gap-1.5' : 'gap-2',
)}
>
{Array.from({ length: INVITE_GOAL }, (_, index) => {
const isFilled = index < joined;
const referredUser = referredUsers[index];

if (isFilled && referredUser) {
return (
<ProfilePicture
key={`invite-slot-${index}`}
user={referredUser}
size={isCompact ? ProfileImageSize.Small : ProfileImageSize.Medium}
/>
);
}

return (
<span
key={`invite-slot-${index}`}
className={classNames(
'flex items-center justify-center',
isCompact ? 'size-6 rounded-8' : 'size-8 rounded-10',
isFilled
? 'bg-surface-float text-text-secondary'
: 'border border-dashed border-border-subtlest-primary text-text-quaternary',
)}
>
{isFilled ? <VIcon /> : <AddUserIcon secondary />}
</span>
);
})}
</div>
);

// The referral reward tracker. While the goal is open it stays a quiet one-line
// row; once it's met the reward is the point of the section, so it becomes a
// full-width Plus-accented card that names the reward and when it runs.
export const InviteRewardProgress = ({
joinedCount,
referredUsers,
rewardPeriod,
className,
}: InviteRewardProgressProps): ReactElement => {
const joined = Math.min(Math.max(0, joinedCount), INVITE_GOAL);
const isCompleted = joined >= INVITE_GOAL;

if (isCompleted) {
return (
<div
className={classNames(
'w-full rounded-16 bg-action-plus-float p-3',
className,
)}
>
<div className="flex items-start gap-2.5">
<span className="relative shrink-0 motion-safe:animate-reward-pop">
<span className="grid size-8 place-items-center rounded-10 bg-action-plus-default text-white">
<DevPlusIcon secondary size={IconSize.XSmall} />
</span>
{/* The "earned it" mark, notched into the corner the way a badge
sits on an avatar. */}
<span className="absolute -bottom-0.5 -right-0.5 grid size-4 place-items-center rounded-full border-2 border-background-default bg-status-success text-white">
<VIcon size={IconSize.XXSmall} />
</span>
</span>
<div className="flex min-w-0 flex-1 flex-col">
<Typography type={TypographyType.Body} bold>
1 month of Plus unlocked
</Typography>
{rewardPeriod && (
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
<time dateTime={rewardPeriod.startsAt.toISOString()}>
{formatRewardDate(rewardPeriod.startsAt)}
</time>
{' – '}
<time dateTime={rewardPeriod.endsAt.toISOString()}>
{formatRewardDate(rewardPeriod.endsAt)}
</time>
</Typography>
)}
</div>
</div>
<div className="mt-2.5 flex items-center gap-2 border-t border-border-subtlest-tertiary pt-2.5">
<InviteSlots
isCompact
joined={joined}
referredUsers={referredUsers}
/>
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
All {INVITE_GOAL} friends joined
</Typography>
</div>
</div>
);
}

return (
<div className={classNames('flex flex-col items-start gap-2', className)}>
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
{joined} of {INVITE_GOAL} friends joined
</Typography>
<div className="flex flex-wrap items-center gap-2">
<InviteSlots joined={joined} referredUsers={referredUsers} />
{/* The connector is the first thing to go on narrow phones, where the
slots and the reward chip only just fit on one line. */}
<span className="hidden h-px w-3 bg-border-subtlest-tertiary mobileL:block" />
<span className="flex h-8 items-center gap-1 rounded-10 border border-transparent bg-action-plus-float px-3 font-bold text-action-plus-default typo-footnote">
<DevPlusIcon secondary size={IconSize.Size16} />1 month of Plus
</span>
</div>
</div>
);
};
1 change: 0 additions & 1 deletion packages/shared/src/components/referral/index.ts

This file was deleted.

47 changes: 47 additions & 0 deletions packages/shared/src/components/share/CopyStateIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import { CopyIcon, VIcon } from '../icons';
import type { IconProps } from '../Icon';

/**
* easeOutExpo — the curve the design-system dropdown animates on. It
* decelerates into the target with no overshoot, which is what keeps a swap
* from reading as a wobble.
*/
export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]';

/**
* A copy is a rare, deliberate moment, so the confirmation earns real motion.
* Both glyphs share one grid cell so the label never shifts mid-swap, and the
* transition collapses to an instant swap under `prefers-reduced-motion`.
*/
export const CopyStateIcon = ({
copied,
className,
...props
}: IconProps & { copied: boolean }): ReactElement => {
const layer = classNames(
className,
'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none',
EASE_OUT_EXPO,
);

return (
<span className="inline-grid">
<CopyIcon
{...props}
className={classNames(layer, copied && 'scale-50 opacity-0 blur-[2px]')}
/>
<VIcon
{...props}
secondary
className={classNames(
layer,
'text-status-success',
!copied && 'scale-50 opacity-0 blur-[2px]',
)}
/>
</span>
);
};
Loading
Loading