diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index ecc1a94c9c1..0ad8c30a4d8 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -22,6 +22,7 @@ import { AddUserIcon, SquadIcon, MegaphoneIcon, + DevPlusIcon, } from '../icons'; import type { NotificationPromptSource } from '../../lib/log'; import { BookmarkReminderIcon } from '../icons/Bookmark/Reminder'; @@ -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 { @@ -118,6 +121,7 @@ export enum NotificationIconType { Core = 'Core', Analytics = 'Analytics', Opportunity = 'Opportunity', + DevPlus = 'DevPlus', } export const notificationIcon: Record< @@ -141,6 +145,7 @@ export const notificationIcon: Record< [NotificationIconType.Core]: CoreIcon, [NotificationIconType.Analytics]: AnalyticsIcon, [NotificationIconType.Opportunity]: JobIcon, + [NotificationIconType.DevPlus]: DevPlusIcon, }; export const notificationIconAsPrimary: NotificationIconType[] = [ @@ -166,6 +171,7 @@ export const notificationIconTypeTheme: Record = { [NotificationIconType.Core]: '', [NotificationIconType.Analytics]: 'text-brand-default', [NotificationIconType.Opportunity]: 'text-black', + [NotificationIconType.DevPlus]: 'text-action-plus-default', }; export const notificationIconStyle: Record< @@ -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> = @@ -430,6 +437,8 @@ export const notificationCategoryToTypes: Record< NotificationType.WarmIntro, NotificationType.ExperienceCompanyEnriched, NotificationType.LiveRoomStarted, + NotificationType.ReferralFriendJoined, + NotificationType.ReferralPlusUnlocked, ], }; diff --git a/packages/shared/src/components/referral/InviteLinkInput.tsx b/packages/shared/src/components/referral/InviteLinkInput.tsx index 1d36abb6f5f..b00934aa136 100644 --- a/packages/shared/src/components/referral/InviteLinkInput.tsx +++ b/packages/shared/src/components/referral/InviteLinkInput.tsx @@ -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'; @@ -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({ @@ -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(); @@ -56,13 +67,15 @@ export function InviteLinkInput({ value={link} fieldType="tertiary" actionButton={ - + actionButton ?? ( + + ) } readOnly /> diff --git a/packages/shared/src/components/referral/InviteRewardProgress.tsx b/packages/shared/src/components/referral/InviteRewardProgress.tsx new file mode 100644 index 00000000000..64999c21c19 --- /dev/null +++ b/packages/shared/src/components/referral/InviteRewardProgress.tsx @@ -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. +
+ {Array.from({ length: INVITE_GOAL }, (_, index) => { + const isFilled = index < joined; + const referredUser = referredUsers[index]; + + if (isFilled && referredUser) { + return ( + + ); + } + + return ( + + {isFilled ? : } + + ); + })} +
+); + +// 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 ( +
+
+ + + + + {/* The "earned it" mark, notched into the corner the way a badge + sits on an avatar. */} + + + + +
+ + 1 month of Plus unlocked + + {rewardPeriod && ( + + + {' – '} + + + )} +
+
+
+ + + All {INVITE_GOAL} friends joined + +
+
+ ); + } + + return ( +
+ + {joined} of {INVITE_GOAL} friends joined + +
+ + {/* The connector is the first thing to go on narrow phones, where the + slots and the reward chip only just fit on one line. */} + + + 1 month of Plus + +
+
+ ); +}; diff --git a/packages/shared/src/components/referral/index.ts b/packages/shared/src/components/referral/index.ts deleted file mode 100644 index 3535a46217f..00000000000 --- a/packages/shared/src/components/referral/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './InviteLinkInput'; diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx new file mode 100644 index 00000000000..6c5defa913f --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.tsx @@ -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 ( + + + + + ); +}; diff --git a/packages/shared/src/components/share/ShareActions.spec.tsx b/packages/shared/src/components/share/ShareActions.spec.tsx new file mode 100644 index 00000000000..5a86a8de95c --- /dev/null +++ b/packages/shared/src/components/share/ShareActions.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { ShareActions } from './ShareActions'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { ShareProvider } from '../../lib/share'; +import { useViewSize } from '../../hooks/useViewSize'; + +jest.mock('../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const onShare = jest.fn(); +const link = 'https://daily.dev/posts/abc'; +const text = 'Check this out'; + +beforeEach(() => { + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); // default: laptop + Object.assign(navigator, { + clipboard: { writeText }, + }); +}); + +const renderComponent = ( + props: Partial[0]> = {}, +): RenderResult => { + const client = new QueryClient(); + return render( + + + , + ); +}; + +describe('ShareActions inline variant', () => { + it('renders copy link plus a compact set of social networks', () => { + renderComponent({ variant: 'inline' }); + + expect(screen.getByText('Copy link')).toBeInTheDocument(); + expect(screen.getByText('X')).toBeInTheDocument(); + expect(screen.getByText('WhatsApp')).toBeInTheDocument(); + }); + + it('copies the link and reports the CopyLink provider', async () => { + renderComponent({ variant: 'inline' }); + + await act(async () => { + fireEvent.click(screen.getByText('Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink); + }); +}); + +describe('ShareActions icon variant on mobile', () => { + beforeEach(() => useViewSizeMock.mockReturnValue(false)); + + it('copies on a single tap when native share is unavailable', async () => { + renderComponent(); + + const trigger = screen.getByLabelText('Copy link'); + await act(async () => { + fireEvent.click(trigger); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink); + }); +}); diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx new file mode 100644 index 00000000000..6ba3645b50a --- /dev/null +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -0,0 +1,211 @@ +import type { ReactElement } from 'react'; +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; +import { PopoverContent } from '../popover/Popover'; +import { SocialShareList } from '../widgets/SocialShareList'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { Typography, TypographyType } from '../typography/Typography'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { shouldUseNativeShare } from '../../lib/func'; +import { ShareProvider } from '../../lib/share'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import { CopyStateIcon } from './CopyStateIcon'; +import { SplitShareButton } from './SplitShareButton'; + +export type ShareActionsVariant = 'icon' | 'inline' | 'split'; + +export interface ShareActionsProps { + link: string; + /** Share text / description used for native share + pre-filled network text. */ + text: string; + cid?: ReferralCampaignKey; + variant?: ShareActionsVariant; + /** Desktop only: reveal the popover on hover as well as click. */ + openOnHover?: boolean; + buttonVariant?: ButtonVariant; + buttonSize?: ButtonSize; + /** Tooltip + accessible label for the icon-only trigger. */ + label?: string; + /** + * Render `triggerText` beside the icon instead of an icon-only trigger. Gated + * so existing icon-only consumers keep their exact DOM. + */ + triggerText?: string; + /** `split` variant only: label for the chevron half that opens the list. */ + dropdownLabel?: string; + emailTitle?: string; + emailSummary?: string; + /** Off for links that are already short and carry their own attribution. */ + shortenUrl?: boolean; + className?: string; + /** Called for any share/copy so the caller can log with its own origin. */ + onShare?: (provider: ShareProvider) => void; +} + +const HOVER_CLOSE_DELAY = 120; + +export function ShareActions({ + link, + text, + cid, + variant = 'icon', + openOnHover = false, + buttonVariant = ButtonVariant.Tertiary, + buttonSize = ButtonSize.Small, + label = 'Copy link', + triggerText, + dropdownLabel = 'More share options', + emailTitle, + emailSummary, + shortenUrl = true, + className, + onShare, +}: ShareActionsProps): ReactElement { + const isLaptop = useViewSize(ViewSize.Laptop); + const [open, setOpen] = useState(false); + const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); + const closeTimeout = useRef>(); + + const onCopy = () => { + onShare?.(ShareProvider.CopyLink); + shareOrCopy(); + }; + + // `copying` stays true for a second after a copy, which is the whole window + // for the confirmation. The green-check swap is scoped to the split control — + // icon-only triggers keep the existing `secondary` fill so this does not + // restyle every share surface in the app. + const copyIcon = + variant === 'split' ? ( + + ) : ( + + ); + + const list = ( + { + onShare?.(ShareProvider.Native); + shareOrCopy(); + }} + onClickSocial={(provider) => onShare?.(provider)} + /> + ); + + if (variant === 'inline') { + return ( +
+ {list} +
+ ); + } + + // Mobile: a single tap goes straight to the native share sheet (or copy when + // native share is unavailable) — no popover, per sharing UX guidance. + if (!isLaptop) { + return ( + + + + ); + } + + if (variant === 'split') { + return ( + + ); + } + + const cancelClose = () => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current); + } + }; + const hoverProps = openOnHover + ? { + onMouseEnter: () => { + cancelClose(); + setOpen(true); + }, + onMouseLeave: () => { + closeTimeout.current = setTimeout( + () => setOpen(false), + HOVER_CLOSE_DELAY, + ); + }, + } + : undefined; + + return ( + + + + + + + + + Share + + {list} + + + ); +} diff --git a/packages/shared/src/components/share/SplitShareButton.tsx b/packages/shared/src/components/share/SplitShareButton.tsx new file mode 100644 index 00000000000..ca76c9e7e0b --- /dev/null +++ b/packages/shared/src/components/share/SplitShareButton.tsx @@ -0,0 +1,195 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { Tooltip } from '../tooltip/Tooltip'; +import { CopyStateIcon, EASE_OUT_EXPO } from './CopyStateIcon'; + +export interface SplitShareButtonProps { + /** Tooltip and accessible name for the copy half. */ + label: string; + /** Accessible name for the chevron half. */ + dropdownLabel: string; + /** Visible text on the copy half; falls back to `label`. */ + triggerText?: string; + /** Contents of the dropdown the chevron opens. */ + menu: ReactNode; + /** Drives the copy-glyph confirmation swap. */ + copied: boolean; + onCopy: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} + +/** + * The halves meet at a single hairline, so the standard side paddings would + * leave a canyon around it. Both sides tighten by one step from the DS value — + * the outer edges keep the standard padding, so the control still reads as one + * button. + */ +const MAIN_INNER_PADDING: Record = { + [ButtonSize.XLarge]: '!pr-5', + [ButtonSize.Large]: '!pr-4', + [ButtonSize.Medium]: '!pr-3', + [ButtonSize.Small]: '!pr-2', + [ButtonSize.XSmall]: '!pr-1.5', +}; + +/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ +const CHEVRON_PADDING: Record = { + [ButtonSize.XLarge]: '!w-auto !px-3', + [ButtonSize.Large]: '!w-auto !px-2.5', + [ButtonSize.Medium]: '!w-auto !px-2', + [ButtonSize.Small]: '!w-auto !px-1.5', + [ButtonSize.XSmall]: '!w-auto !px-1', +}; + +const DIVIDER_BASE = + "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; + +/** + * Variants that paint `--button-default-border-color: transparent` have no + * border for the divider to match, so they draw their own 1px rule. Every other + * variant returns nothing and keeps its real border as the divider. + * + * Alpha is mixed into the colour rather than applied as a separate utility: + * `before:opacity-*` does not survive this project's Tailwind build on + * pseudo-elements and silently renders at full strength. `bg-current` is + * likewise unusable — the theme replaces Tailwind's `colors` wholesale and has + * no `current` key, so it compiles to nothing at all. + */ +const dividerFor = (variant: ButtonVariant): string | false => { + // Sits on a solid fill, where only the label colour is guaranteed to read. + if (variant === ButtonVariant.Primary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', + ); + } + + // A bare ghost button: a full-height rule would float with nothing to anchor + // it, so it gets a shorter one in the colour `tailwind/buttons.ts` gives the + // Subtle variant's border. + if (variant === ButtonVariant.Tertiary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', + ); + } + + return false; +}; + +/** + * The halves keep their clicks to themselves: a parent that acts on clicks + * steals focus the moment the menu opens, which dismisses it right away — e.g. + * a TextField, whose container focuses its input on click. + */ +const stopParentClick = (event: React.MouseEvent): void => + event.stopPropagation(); + +/** + * Two real buttons that read as one control: the left half runs the primary + * action, the right half drops the standard menu. Geometry matches a standard + * button at every size — only the shared edge deviates. + */ +export const SplitShareButton = ({ + label, + dropdownLabel, + triggerText, + menu, + copied, + onCopy, + variant = ButtonVariant.Tertiary, + size = ButtonSize.Small, + className, +}: SplitShareButtonProps): ReactElement => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx new file mode 100644 index 00000000000..fcd2c2143c8 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { webappUrl } from '../../../lib/constants'; +import { cloudinaryCharmGiveback } from '../../../lib/image'; + +interface GivebackInviteCardProps { + onClick?: () => void; + className?: string; +} + +const charmCutoutId = 'giveback-charm-cutout'; + +// Cross-promo into /giveback for surfaces outside the campaign (today: the +// invite settings page). It wears the same treatment as the founding-reward +// card in GivebackFoundingAward: a 1px gradient frame over an opaque base +// washed with the same gradient at a true 8% via color-mix (Tailwind's +// `/opacity` modifier doesn't take on these token gradient stops). +export const GivebackInviteCard = ({ + onClick, + className, +}: GivebackInviteCardProps): ReactElement => ( +
+
+
+ + Community giveback + + + Turn community actions into real donations + + + We redirect our growth budget to causes the community picks. You never + pay a cent. + + +
+ {/* Not GivebackMascot: its `h-44 tablet:h-56` is baked into the same + class string a caller would override, and class names here are + concatenated rather than merged, so the hero size always wins. This + card needs a compact charm, so it sizes its own. */} + + {/* The render ships on solid black. `mix-blend-screen` only drops that + over a dark surface, so on the light theme the charm blended away + entirely. Cut the black out instead: derive the alpha channel from + the artwork's own brightness, so it reads on any background. The + ramp keeps the mid-dark parts of the art (the podium, the phone + bezel) fully opaque rather than fading them out. */} + + + + + + + + + + + +
+
+); diff --git a/packages/storybook/stories/components/notifications/_mock.tsx b/packages/storybook/stories/components/notifications/_mock.tsx index 6815592a041..d75ce10d35e 100644 --- a/packages/storybook/stories/components/notifications/_mock.tsx +++ b/packages/storybook/stories/components/notifications/_mock.tsx @@ -1,4 +1,5 @@ import { fn } from 'storybook/test'; +import { addMonths, format } from 'date-fns'; import type { NotificationItemProps } from '@dailydotdev/shared/src/components/notifications/NotificationItem'; import { NotificationIconType, @@ -22,6 +23,9 @@ const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000); const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000); const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000); +// The referral reward runs from the moment the third friend joined. +const rewardStart = hoursAgo(5); + export const userAvatar = (seed: string, name: string) => ({ type: NotificationAvatarType.User, referenceId: seed, @@ -84,6 +88,28 @@ const defs: Array & { isUnread?: boolean }> = [ createdAt: hoursAgo(3), isUnread: true, }, + { + type: NotificationType.ReferralPlusUnlocked, + icon: NotificationIconType.DevPlus, + title: '3 friends joined, so your free month of Plus is unlocked', + description: `Active ${format(rewardStart, 'd MMM yyyy')} – ${format( + addMonths(rewardStart, 1), + 'd MMM yyyy', + )}`, + targetUrl: '/settings/invite', + createdAt: hoursAgo(5), + isUnread: true, + }, + { + type: NotificationType.ReferralFriendJoined, + icon: NotificationIconType.User, + title: 'Wren Halloway joined daily.dev through your invite', + description: '3 of 3 friends joined', + avatars: [userAvatar('wren', 'Wren Halloway')], + targetUrl: '/settings/invite', + createdAt: hoursAgo(5), + isUnread: true, + }, { type: NotificationType.UserFollow, icon: NotificationIconType.User, @@ -183,6 +209,15 @@ export const sampleNotifications: Array< ...def, })); +// The referral-reward rows, pulled out so the Invite friends stories can show +// them on their own without re-declaring the copy. +export const referralNotifications = sampleNotifications.filter(({ type }) => + [ + NotificationType.ReferralPlusUnlocked, + NotificationType.ReferralFriendJoined, + ].includes(type), +); + // Same coarse buckets the live page uses (see NotificationsFeed.tsx). export const TIME_GROUPS = [ { key: 'today', label: 'Today', maxDays: 0 }, diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx new file mode 100644 index 00000000000..86235191b24 --- /dev/null +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -0,0 +1,403 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { screen, userEvent } from 'storybook/test'; +import { addMonths, format } from 'date-fns'; +import type { InviteRewardPeriod } from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; +import { + INVITE_GOAL, + InviteRewardProgress, +} from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; +import { GivebackInviteCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackInviteCard'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import UserList from '@dailydotdev/shared/src/components/profile/UserList'; +import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; +import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { referralNotifications } from '../../components/notifications/_mock'; +import { + INVITE_LINK, + INVITE_TEXT, + mockReferredUsers, + withInvite, +} from './inviteFriends.mocks'; + +// Stand-ins for the webapp settings chrome (AccountPageContainer / +// AccountContentSection), so the real components can be judged in the layout +// they actually ship in. The page body itself lives in the webapp package and +// can't be imported here. +const SettingsCard = ({ children }: { children: ReactNode }): ReactElement => ( +
+
+

+ Invite friends +

+
+ {children} +
+
+
+); + +const Section = ({ + title, + description, + isFirst = false, + children, +}: { + title: string; + description?: string; + isFirst?: boolean; + children?: ReactNode; +}): ReactElement => ( + <> +

+ {title} +

+ {description && ( +

{description}

+ )} + {children} + +); + +interface InvitePageProps { + joinedCount?: number; + isPlus?: boolean; + showGiveback?: boolean; +} + +// Mirrors packages/webapp/pages/settings/invite.tsx section for section. +const rewardDescription = (isUnlocked: boolean): string => + isUnlocked + ? 'Keep inviting. Every developer you bring makes the feed sharper.' + : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; + +// Same derivation as the page: the free month runs from the join date of the +// INVITE_GOALth developer. +const rewardPeriodFor = ( + referredUsers: UserShortProfile[], +): InviteRewardPeriod | undefined => { + const completing = referredUsers[INVITE_GOAL - 1]; + + if (!completing) { + return undefined; + } + + const startsAt = new Date(completing.createdAt); + + return { startsAt, endsAt: addMonths(startsAt, 1) }; +}; + +const InvitePage = ({ + joinedCount = 0, + isPlus = false, + showGiveback = true, +}: InvitePageProps): ReactElement => { + const referredUsers = mockReferredUsers().slice(0, joinedCount); + const isUnlocked = joinedCount >= INVITE_GOAL; + + return ( + +
+ {isPlus && !isUnlocked && ( + + Already on Plus? The free month is added to your subscription. + + )} + +
+
+ + } + /> +
+ {showGiveback && ( +
+ +
+ )} +
+
+ Promise.resolve(), + }} + emptyPlaceholder={ +
+ +

+ No one has joined yet. Share your link! +

+
+ } + userInfoProps={{ + transformUsername({ + username, + createdAt, + }: UserShortProfile): ReactNode { + return ( + + @{username} + + + + ); + }, + }} + /> +
+
+
+ ); +}; + +const meta: Meta = { + title: 'Features/Referral/Invite friends page', + component: InvitePage, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Every state of the "Invite friends" settings page (/settings/invite): referral progress from zero to unlocked, the Plus-member variant, and the giveback cross-promo on and off. The reward chip is framing only: no backend grants Plus at 3 referrals yet.', + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const NoInvitesYet: Story = { + name: '0 of 3 (nobody joined)', + args: { joinedCount: 0 }, + decorators: [withInvite()], +}; + +export const OneFriendJoined: Story = { + name: '1 of 3 (first join)', + args: { joinedCount: 1 }, + decorators: [withInvite()], +}; + +export const TwoFriendsJoined: Story = { + name: '2 of 3 (one to go)', + args: { joinedCount: 2 }, + decorators: [withInvite()], +}; + +export const RewardUnlocked: Story = { + name: '3 of 3 (Plus unlocked)', + args: { joinedCount: 3 }, + decorators: [withInvite()], +}; + +export const BeyondTheGoal: Story = { + name: '4 joined (past the goal)', + args: { joinedCount: 4 }, + decorators: [withInvite()], + parameters: { + docs: { + description: { + story: + 'Progress caps at three slots while the referrals list keeps growing.', + }, + }, + }, +}; + +export const ExistingPlusMember: Story = { + name: 'Already a Plus member', + args: { joinedCount: 1, isPlus: true }, + decorators: [withInvite()], + parameters: { + docs: { + description: { + story: + 'Plus subscribers get a note that the free month stacks on their current subscription. It disappears once the reward unlocks.', + }, + }, + }, +}; + +export const GivebackDisabled: Story = { + name: 'Giveback flag off', + args: { joinedCount: 1, showGiveback: false }, + decorators: [withInvite()], +}; + +export const Mobile: Story = { + name: 'Mobile width', + args: { joinedCount: 2 }, + decorators: [withInvite()], + globals: { viewport: { value: 'mobile1' } }, +}; + +// Component-level states, side by side, for judging the tracker on its own. +export const ProgressStates: StoryObj = { + name: 'Reward progress (all steps)', + render: () => ( +
+ {[0, 1, 2, 3].map((count) => ( +
+ + joinedCount = {count} + + +
+ ))} +
+ ), + decorators: [withInvite()], + parameters: { controls: { disable: true } }, +}; + +// The two notifications this feature emits, in the row layout the +// /notifications page renders them in. They also sit in the shared +// notifications mock, so "Components/Notifications/Full page" shows them in +// context alongside the rest of the feed. +export const Notifications: StoryObj = { + name: 'Notifications', + render: () => ( +
+ {referralNotifications.map((notification) => ( + + ))} +
+ ), + decorators: [withInvite()], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Both rows deep-link to /settings/invite. `referral_friend_joined` fires per join and carries the running count; `referral_plus_unlocked` fires once on the third and states the period the free month covers. Unread rows sit on the raised surface, same as the rest of the feed. Neither type is emitted by the backend yet.', + }, + }, + }, +}; + +// The share control on its own, with the menu already open, so the dropdown can +// be judged without hunting for it in the full page. +export const ShareMenuOpen: StoryObj = { + name: 'Invite link (share menu open)', + render: () => ( +
+ + } + /> +
+ ), + decorators: [withInvite()], + play: async () => { + await userEvent.click(await screen.findByLabelText('More ways to share')); + }, + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Left half copies the link and swaps the glyph to a checkmark for a second. Right half opens the share list. Below laptop width the control collapses to a single button that opens the native share sheet, so there is no menu to open.', + }, + }, + }, +}; + +export const GivebackCard: StoryObj = { + name: 'Giveback cross-promo card', + render: () => ( +
+ +
+ ), + decorators: [withInvite()], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Wears the same treatment as the giveback founding-reward card: a 1px gradient frame over an opaque base washed with the same gradient at 8%.', + }, + }, + }, +}; diff --git a/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx new file mode 100644 index 00000000000..5346a852183 --- /dev/null +++ b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx @@ -0,0 +1,126 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import type { Decorator } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; + +const noop = (): void => undefined; + +export const MOCK_USER = { + id: 'sb-user', + name: 'Dev Dana', + username: 'devdana', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/devdana', + bio: null, + createdAt: '2021-01-01T00:00:00.000Z', + reputation: 42, + providers: ['github'], +} as const; + +export const INVITE_LINK = 'https://api.daily.dev/get?r=devdana'; + +// Mirrors labels.referral.generic.inviteText, the text the real page shares. +export const INVITE_TEXT = `I'm using daily.dev to stay updated on developer news. I think you will find it helpful:`; + +// The developers who joined through the link. Stories slice this list to get a +// given progress state, so slot N is always the same person. +export const mockReferredUsers = (): UserShortProfile[] => + [ + { + id: 'ref-1', + name: 'Ida Kern', + username: 'idakern', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/idakern', + createdAt: '2026-05-04T10:00:00.000Z', + reputation: 120, + }, + { + id: 'ref-2', + name: 'Ravi Menon', + username: 'ravimenon', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/ravimenon', + createdAt: '2026-06-11T10:00:00.000Z', + reputation: 340, + }, + { + id: 'ref-3', + name: 'Wren Halloway', + username: 'wrenh', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/wrenh', + createdAt: '2026-07-02T10:00:00.000Z', + reputation: 88, + }, + { + id: 'ref-4', + name: 'Søren Bakke', + username: 'sorenb', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/sorenb', + createdAt: '2026-07-19T10:00:00.000Z', + reputation: 15, + }, + ] as UserShortProfile[]; + +const InviteProviders = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + const queryClient = useMemo(() => new QueryClient(), []); + const LogContext = getLogContextStatic(); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + + {children} + + + + ); +}; + +// The stories drive Plus/flag state through args, so the providers only need to +// supply a signed-in user, a query client, and a no-op log context. +export const withInvite = + (): Decorator => + (Story) => + ( + + + + ); diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx new file mode 100644 index 00000000000..678b8e34c57 --- /dev/null +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import nock from 'nock'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql'; +import type { Visit } from '@dailydotdev/shared/src/lib/boot'; +import { + REFERRAL_CAMPAIGN_QUERY, + REFERRED_USERS_QUERY, +} from '@dailydotdev/shared/src/graphql/users'; +import type { Feature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; + +import AccountInvitePage from '../pages/settings/invite'; + +const LogContext = getLogContextStatic(); + +jest.mock('next/router', () => ({ + useRouter() { + return { + isFallback: false, + pathname: '/settings/invite', + query: {}, + push: jest.fn(), + replace: jest.fn(), + events: { on: jest.fn(), off: jest.fn() }, + }; + }, +})); + +// The page gates the giveback section on `featureGiveback`; other features in +// the tree should keep their defaults, so the mock resolves per feature. +const mockedFeatures = new Map, unknown>(); +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + useConditionalFeature: ({ feature }: { feature: Feature }) => ({ + value: mockedFeatures.has(feature) + ? mockedFeatures.get(feature) + : feature.defaultValue, + isLoading: false, + }), +})); + +let client: QueryClient; +const logEvent = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + mockedFeatures.clear(); + client = new QueryClient(); +}); + +const defaultVisit: Visit = { + sessionId: 'sample session id', + visitId: 'sample visit id', +}; + +const mockReferralCampaign = (referredUsersCount: number) => { + mockGraphQL({ + request: { + query: REFERRAL_CAMPAIGN_QUERY, + variables: { referralOrigin: 'generic' }, + }, + result: { + data: { + referralCampaign: { + referredUsersCount, + referralCountLimit: 5, + referralToken: 'token', + url: 'https://daily.dev/join?token=token', + }, + }, + }, + }); +}; + +const referredUser = ( + id: string, + username: string, + createdAt = '2026-05-04T10:00:00.000Z', +) => ({ + id, + name: username, + username, + image: `https://daily.dev/${username}.jpg`, + permalink: `https://app.daily.dev/${username}`, + createdAt, + reputation: 10, +}); + +const mockReferredUsers = (users: ReturnType[] = []) => { + mockGraphQL({ + request: { query: REFERRED_USERS_QUERY, variables: { after: '' } }, + result: { + data: { + referredUsers: { + pageInfo: { endCursor: null, hasNextPage: false }, + edges: users.map((node) => ({ node })), + }, + }, + }, + }); +}; + +const renderComponent = ( + user: LoggedUser = loggedUser, + users: ReturnType[] = [], +): RenderResult => { + mockReferredUsers(users); + + return render( + + + + + + + , + ); +}; + +it('should render the 3-invites promo with the progress at zero', async () => { + mockReferralCampaign(0); + renderComponent(); + + expect( + await screen.findByText('Invite 3 friends, get 1 month of Plus'), + ).toBeInTheDocument(); + expect(screen.getByText('0 of 3 friends joined')).toBeInTheDocument(); + expect(screen.getByText('Your invitation link')).toBeInTheDocument(); +}); + +it('should reflect partial progress from the referral campaign', async () => { + mockReferralCampaign(2); + renderComponent(); + + expect(await screen.findByText('2 of 3 friends joined')).toBeInTheDocument(); +}); + +it('should show the unlocked state once three friends joined', async () => { + mockReferralCampaign(3); + renderComponent(); + + expect( + await screen.findByText('1 month of Plus unlocked'), + ).toBeInTheDocument(); + expect(screen.getByText('All 3 friends joined')).toBeInTheDocument(); +}); + +it('should keep the unlocked copy count-agnostic beyond the goal', async () => { + mockReferralCampaign(12); + renderComponent(); + + expect( + await screen.findByText('1 month of Plus unlocked'), + ).toBeInTheDocument(); + expect(screen.getByText('All 3 friends joined')).toBeInTheDocument(); + expect(screen.queryByText(/12 of 3/)).not.toBeInTheDocument(); +}); + +it('should run the free month from the join date of the third referral', async () => { + mockReferralCampaign(3); + renderComponent(loggedUser, [ + // Deliberately out of order — the period is derived from the third + // developer to join, not the third row the query returns. + referredUser('ref-3', 'wrenh', '2026-07-02T10:00:00.000Z'), + referredUser('ref-1', 'idakern', '2026-05-04T10:00:00.000Z'), + referredUser('ref-2', 'ravimenon', '2026-06-11T10:00:00.000Z'), + ]); + + expect(await screen.findByText('2 Jul 2026')).toBeInTheDocument(); + expect(screen.getByText('2 Aug 2026')).toBeInTheDocument(); +}); + +it('should fill the progress slots with the referred users avatars', async () => { + mockReferralCampaign(2); + renderComponent(loggedUser, [ + referredUser('ref-1', 'idakern'), + referredUser('ref-2', 'ravimenon'), + ]); + + await screen.findByText('2 of 3 friends joined'); + await waitFor(() => { + expect(screen.getAllByAltText("idakern's profile")).not.toHaveLength(0); + }); + expect(screen.getAllByAltText("ravimenon's profile")).not.toHaveLength(0); +}); + +it('should link to the giveback page and log the click when enabled', async () => { + mockedFeatures.set(featureGiveback, true); + mockReferralCampaign(0); + renderComponent(); + + expect(await screen.findByText('More ways to give back')).toBeInTheDocument(); + const cta = screen.getByRole('link', { name: 'Explore Giveback' }); + expect(cta).toHaveAttribute('href', `${webappUrl}giveback`); + + fireEvent.click(cta); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ event_name: LogEvent.ClickGivebackGiftEntry }), + ); +}); + +it('should hide the giveback section when the feature is disabled', async () => { + mockedFeatures.set(featureGiveback, false); + mockReferralCampaign(0); + renderComponent(); + + await waitFor(() => { + expect( + screen.queryByText('More ways to give back'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 2f2a11ac0ce..8a7b9779d35 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useMemo, useRef } from 'react'; +import React, { useMemo } from 'react'; import { ReferralCampaignKey, useReferralCampaign, @@ -19,28 +19,38 @@ import UserList from '@dailydotdev/shared/src/components/profile/UserList'; import { checkFetchMore } from '@dailydotdev/shared/src/components/containers/InfiniteScrolling'; import type { ReferredUsersData } from '@dailydotdev/shared/src/graphql/common'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; -import { format } from 'date-fns'; +import { addMonths, format } from 'date-fns'; import { useInfiniteQuery } from '@tanstack/react-query'; import { LogEvent, TargetId, TargetType, } from '@dailydotdev/shared/src/lib/log'; -import type { ShareProvider } from '@dailydotdev/shared/src/lib/share'; -import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; -import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; import type { NextSeoProps } from 'next-seo'; import { Typography, TypographyColor, - TypographyTag, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { usePlusSubscription } from '@dailydotdev/shared/src/hooks/usePlusSubscription'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + INVITE_GOAL, + InviteRewardProgress, +} from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; +import { GivebackInviteCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackInviteCard'; import AccountContentSection from '../../components/layouts/SettingsLayout/AccountContentSection'; import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; @@ -55,20 +65,16 @@ const seo: NextSeoProps = { const AccountInvitePage = (): ReactElement => { const { user } = useAuthContext(); - const container = useRef(); + const { isPlus } = usePlusSubscription(); const referredKey = generateQueryKey(RequestKey.ReferredUsers, user); const { url, referredUsersCount } = useReferralCampaign({ campaignKey: ReferralCampaignKey.Generic, }); const { logEvent } = useLogContext(); const inviteLink = url || link.referral.defaultUrl; - const [, onShareOrCopyLink] = useShareOrCopyLink({ - text: labels.referral.generic.inviteText, - link: inviteLink, - logObject: () => ({ - event_name: LogEvent.CopyReferralLink, - target_id: TargetId.InviteFriendsPage, - }), + const { value: isGivebackEnabled } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate: !!user, }); const usersResult = useInfiniteQuery({ queryKey: referredKey, @@ -89,9 +95,45 @@ const AccountInvitePage = (): ReactElement => { }, []); return list; - }, [usersResult]); + }, [usersResult.data]); + + const isRewardUnlocked = referredUsersCount >= INVITE_GOAL; + + // The free month runs from the moment the goal was met — i.e. the join date + // of the INVITE_GOALth developer. Derived on the client because no backend + // field carries the grant yet; when one lands, read it here instead. Sorts a + // copy rather than trusting the list order, which the query doesn't specify. + const rewardPeriod = useMemo(() => { + if (!isRewardUnlocked) { + return undefined; + } + + const completing = [...users].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + )[INVITE_GOAL - 1]; + + if (!completing) { + return undefined; + } + + const startsAt = new Date(completing.createdAt); + + return { startsAt, endsAt: addMonths(startsAt, 1) }; + }, [isRewardUnlocked, users]); + + // One handler for every route out of the split control: a copy keeps the + // page's existing copy event, anything else logs as a share with the provider. + const onShare = (provider: ShareProvider) => { + if (provider === ShareProvider.CopyLink) { + logEvent({ + event_name: LogEvent.CopyReferralLink, + target_id: TargetId.InviteFriendsPage, + }); + + return; + } - const onLogShare = (provider: ShareProvider) => { logEvent({ event_name: LogEvent.InviteReferral, target_id: provider, @@ -99,93 +141,121 @@ const AccountInvitePage = (): ReactElement => { }); }; + // The card below already announces the reward, so the unlocked copy only has + // to give a reason to keep going. + const rewardDescription = isRewardUnlocked + ? 'Keep inviting. Every developer you bring makes the feed sharper.' + : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; + + const onGivebackClick = () => { + logEvent({ + event_name: LogEvent.ClickGivebackGiftEntry, + target_id: TargetId.InviteFriendsPage, + }); + }; + return ( - + {isPlus && !isRewardUnlocked && ( + + Already on Plus? The free month is added to your subscription. + + )} + + + + } /> - - or invite via - -
- -
+ {isGivebackEnabled && ( + + + + )} - - daily.dev charm inviting your friends -

- No one has joined yet. Share your link! -

- - } - userInfoProps={{ - scrollingContainer: container.current, - className: { - container: 'px-0 py-3 items-center', - textWrapper: 'flex-none', - }, - transformUsername({ - username, - createdAt, - }: UserShortProfile): React.ReactNode { - return ( - - @{username} - - - - ); - }, - }} - placeholderAmount={referredUsersCount} - /> + {/* UserList hard-codes px-6 on its rows and on the loading placeholder, + so cancel the section's own p-6 around the whole list. That keeps + every state (rows, skeletons, empty) aligned with the headings and + lets the row hover highlight run the full width of the card. */} +
+ + +

+ No one has joined yet. Share your link! +

+
+ } + userInfoProps={{ + transformUsername({ + username, + createdAt, + }: UserShortProfile): React.ReactNode { + return ( + + @{username} + + + + ); + }, + }} + placeholderAmount={referredUsersCount} + /> +
); diff --git a/packages/webapp/pages/settings/organization/index.tsx b/packages/webapp/pages/settings/organization/index.tsx index 1e486bd6bd6..aecfd20fcf2 100644 --- a/packages/webapp/pages/settings/organization/index.tsx +++ b/packages/webapp/pages/settings/organization/index.tsx @@ -28,7 +28,7 @@ import { OrganizationIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; import { ReferralCampaignKey,