diff --git a/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx b/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx
index 124260d5bdb..82e9a4ab714 100644
--- a/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx
+++ b/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx
@@ -1,17 +1,9 @@
import type { ReactElement } from 'react';
import React from 'react';
-import { useQuery } from '@tanstack/react-query';
import { useAuthContext } from '../../../contexts/AuthContext';
-import { gqlClient } from '../../../graphql/common';
-import type {
- LeaderboardPosition,
- LeaderboardType,
-} from '../../../graphql/leaderboard';
-import {
- isLeaderboardPositionSupported,
- LEADERBOARD_POSITION_QUERY,
-} from '../../../graphql/leaderboard';
-import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+import type { LeaderboardType } from '../../../graphql/leaderboard';
+import { isLeaderboardPositionSupported } from '../../../graphql/leaderboard';
+import { useLeaderboardPosition } from '../../../hooks/leaderboard/useLeaderboardPosition';
import { largeNumberFormat } from '../../../lib';
import { UserHighlight } from '../../widgets/PostUsersHighlights';
@@ -31,27 +23,16 @@ export function CurrentUserPositionRow({
!visibleUserIds.has(user.id) &&
isLeaderboardPositionSupported(type);
- const { data } = useQuery({
- queryKey: generateQueryKey(RequestKey.LeaderboardPosition, user, type),
- queryFn: async () => {
- const res = await gqlClient.request<{
- leaderboardPosition: LeaderboardPosition | null;
- }>(LEADERBOARD_POSITION_QUERY, { type });
+ const { position } = useLeaderboardPosition({ type, enabled });
- return res.leaderboardPosition;
- },
- enabled,
- staleTime: StaleTime.Default,
- });
-
- if (!enabled || !data) {
+ if (!enabled || !position) {
return null;
}
const rankLabel =
- data.rank === null
- ? `${largeNumberFormat(data.cappedAt)}+`
- : `#${data.rank.toLocaleString()}`;
+ position.rank === null
+ ? `${largeNumberFormat(position.cappedAt)}+`
+ : `#${position.rank.toLocaleString()}`;
return (
diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardListContainer.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardListContainer.tsx
index e4117c99bf5..930d67fc64a 100644
--- a/packages/shared/src/components/cards/Leaderboard/LeaderboardListContainer.tsx
+++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardListContainer.tsx
@@ -1,11 +1,37 @@
import type { ReactElement } from 'react';
import React from 'react';
+import classNames from 'classnames';
import type { LeaderboardListContainerProps } from './common';
import { LeaderboardCard } from './common';
import Link from '../../utilities/Link';
import { ArrowIcon } from '../../icons';
import { IconSize } from '../../Icon';
+interface TitleHeadingProps {
+ title: string;
+ titleHref?: string;
+ className?: string;
+}
+
+const TitleHeading = ({
+ title,
+ titleHref,
+ className,
+}: TitleHeadingProps): ReactElement => (
+
+ {titleHref ? (
+
+
+ {title}
+
+
+
+ ) : (
+ <>{title}>
+ )}
+
+);
+
export function LeaderboardListContainer({
children,
className,
@@ -13,25 +39,20 @@ export function LeaderboardListContainer({
titleHref,
footer,
header,
+ titleAction,
}: LeaderboardListContainerProps): ReactElement {
+ const defaultHeader = titleAction ? (
+
+
+ {titleAction}
+
+ ) : (
+
+ );
+
return (
- {header ?? (
-
- )}
+ {header ?? defaultHeader}
{children}
{footer && {footer}
}
diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.spec.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.spec.tsx
new file mode 100644
index 00000000000..66d84b18d80
--- /dev/null
+++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.spec.tsx
@@ -0,0 +1,183 @@
+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 { LeaderboardRankShare } from './LeaderboardRankShare';
+import type { LeaderboardPosition } from '../../../graphql/leaderboard';
+import { LeaderboardType } from '../../../graphql/leaderboard';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import loggedUser from '../../../../__tests__/fixture/loggedUser';
+import { useLeaderboardShareEnabled } from '../../../hooks/leaderboard/useLeaderboardShareEnabled';
+import { useLeaderboardPosition } from '../../../hooks/leaderboard/useLeaderboardPosition';
+import { useViewSize } from '../../../hooks/useViewSize';
+import { getShortLinkProps } from '../../../hooks/utils/useGetShortUrl';
+import { ReferralCampaignKey } from '../../../lib/referral';
+import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification';
+import type { ToastNotification } from '../../../hooks/useToastNotification';
+
+// `NEXT_PUBLIC_WEBAPP_URL` is '/' under jest, but the referral tracker builds a
+// `new URL(...)` and needs the absolute origin production actually ships.
+jest.mock('../../../lib/constants', () => ({
+ ...jest.requireActual('../../../lib/constants'),
+ webappUrl: 'https://app.daily.dev/',
+}));
+
+jest.mock('../../../hooks/leaderboard/useLeaderboardShareEnabled', () => ({
+ useLeaderboardShareEnabled: jest.fn(),
+}));
+
+jest.mock('../../../hooks/leaderboard/useLeaderboardPosition', () => ({
+ useLeaderboardPosition: jest.fn(),
+}));
+
+jest.mock('../../../hooks/useViewSize', () => {
+ const actual = jest.requireActual('../../../hooks/useViewSize');
+ return { __esModule: true, ...actual, useViewSize: jest.fn() };
+});
+
+const shareEnabledMock = useLeaderboardShareEnabled as jest.Mock;
+const positionMock = useLeaderboardPosition as jest.Mock;
+const viewSizeMock = useViewSize as jest.Mock;
+const writeText = jest.fn().mockResolvedValue(undefined);
+
+const link = 'https://app.daily.dev/users/longestStreak';
+const shortLink = 'https://dly.to/rank12';
+
+const mockPosition = (
+ position: LeaderboardPosition | null,
+ isPending = false,
+) => positionMock.mockReturnValue({ position, isPending });
+
+let client: QueryClient;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ shareEnabledMock.mockReturnValue(true);
+ viewSizeMock.mockReturnValue(true); // laptop
+ mockPosition({ rank: 12, score: 340, cappedAt: 5000 });
+ Object.assign(navigator, { clipboard: { writeText } });
+
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ // Pre-seed the short-URL cache so the copy path never hits the network.
+ const { queryKey } = getShortLinkProps(
+ link,
+ ReferralCampaignKey.Generic,
+ loggedUser,
+ );
+ client.setQueryData(queryKey, shortLink);
+});
+
+afterEach(() => {
+ delete (navigator as unknown as { share?: unknown }).share;
+ globalThis.localStorage.removeItem('mobile');
+});
+
+const renderComponent = (type = LeaderboardType.LongestStreak): RenderResult =>
+ render(
+
+
+ ,
+ );
+
+describe('LeaderboardRankShare', () => {
+ it('renders the rank headline and the pre-filled share text', () => {
+ renderComponent();
+
+ expect(screen.getByText('#12')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "I'm #12 on the daily.dev longest streak leaderboard. Still climbing.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('uses the top-tier copy for a top three rank', () => {
+ mockPosition({ rank: 2, score: 900, cappedAt: 5000 });
+ renderComponent();
+
+ expect(
+ screen.getByText(
+ "Somehow I'm #2 on the daily.dev longest streak leaderboard. Come take the spot.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('renders nothing while the position is still loading', () => {
+ mockPosition(null, true);
+ const { container } = renderComponent();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when the viewer sits past the ranked cap', () => {
+ mockPosition({ rank: null, score: 3, cappedAt: 5000 });
+ const { container } = renderComponent();
+
+ expect(container).toBeEmptyDOMElement();
+ expect(screen.queryByText(/#undefined|#null/)).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when the API returns no position at all', () => {
+ mockPosition(null);
+ const { container } = renderComponent();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing for a leaderboard without position support', () => {
+ const { container } = renderComponent(LeaderboardType.MostUpvoted);
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when the flag is off', () => {
+ shareEnabledMock.mockReturnValue(false);
+ const { container } = renderComponent();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('copies the tracked link and shows a toast on desktop', async () => {
+ renderComponent();
+
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Share your rank'));
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Copy link'));
+ });
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortLink));
+ await waitFor(() =>
+ expect(
+ (client.getQueryData(TOAST_NOTIF_KEY) as ToastNotification)?.message,
+ ).toEqual('✅ Copied link to clipboard'),
+ );
+ });
+
+ it('opens the native share sheet with the rank text on mobile', async () => {
+ viewSizeMock.mockReturnValue(false);
+ globalThis.localStorage.setItem('mobile', 'true');
+ const share = jest.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, { share });
+
+ renderComponent();
+
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Share your rank'));
+ });
+
+ await waitFor(() => expect(share).toHaveBeenCalled());
+ expect(share.mock.calls[0][0].text).toContain(
+ "I'm #12 on the daily.dev longest streak leaderboard",
+ );
+ expect(share.mock.calls[0][0].text).toContain(shortLink);
+ });
+});
diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx
new file mode 100644
index 00000000000..090b0929741
--- /dev/null
+++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx
@@ -0,0 +1,128 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import { ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { ShareActions } from '../../share/ShareActions';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../../typography/Typography';
+import { TopRankBadge } from './TopRankBadge';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { useLogContext } from '../../../contexts/LogContext';
+import type { LeaderboardType } from '../../../graphql/leaderboard';
+import {
+ isLeaderboardPositionSupported,
+ leaderboardTypeToTitle,
+} from '../../../graphql/leaderboard';
+import { useLeaderboardPosition } from '../../../hooks/leaderboard/useLeaderboardPosition';
+import { useLeaderboardShareEnabled } from '../../../hooks/leaderboard/useLeaderboardShareEnabled';
+import { ReferralCampaignKey } from '../../../lib/referral';
+import {
+ getLeaderboardRankShareText,
+ getLeaderboardUrl,
+} from '../../../lib/leaderboardShare';
+import { LogEvent, Origin } from '../../../lib/log';
+
+interface LeaderboardRankCardProps {
+ type: LeaderboardType;
+ rank: number;
+ className?: string;
+}
+
+// Presentation only — takes a resolved rank so Storybook can render every
+// state. Product surfaces should use `LeaderboardRankShare`, which owns the
+// flag gate and the "no honest rank to share" cases.
+export function LeaderboardRankCard({
+ type,
+ rank,
+ className,
+}: LeaderboardRankCardProps): ReactElement {
+ const { logEvent } = useLogContext();
+ const board = leaderboardTypeToTitle[type].toLowerCase();
+ const text = getLeaderboardRankShareText(type, rank);
+
+ return (
+
+
+
+
+ You're #{rank} on the{' '}
+ {board} leaderboard
+
+
+ {text}
+
+
+
+ logEvent({
+ event_name: LogEvent.ShareLeaderboard,
+ target_id: type,
+ extra: JSON.stringify({
+ provider,
+ rank,
+ origin: Origin.Leaderboard,
+ }),
+ })
+ }
+ />
+
+ );
+}
+
+interface LeaderboardRankShareProps {
+ type: LeaderboardType;
+ className?: string;
+}
+
+export function LeaderboardRankShare({
+ type,
+ className,
+}: LeaderboardRankShareProps): ReactElement | null {
+ const isEnabled = useLeaderboardShareEnabled();
+ const { user, isAuthReady } = useAuthContext();
+ const isSupported = isLeaderboardPositionSupported(type);
+ const { position, isPending } = useLeaderboardPosition({
+ type,
+ enabled: isEnabled && isAuthReady && !!user && isSupported,
+ });
+
+ if (!isEnabled || !user || !isSupported || isPending) {
+ return null;
+ }
+
+ // `rank` is null once the viewer sits past `cappedAt` — there is no honest
+ // number to put in the share text, so no rank-share affordance either.
+ if (!position || position.rank === null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.spec.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.spec.tsx
new file mode 100644
index 00000000000..14041b83e16
--- /dev/null
+++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.spec.tsx
@@ -0,0 +1,136 @@
+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 { LeaderboardShareButton } from './LeaderboardShareButton';
+import { LeaderboardListContainer } from './LeaderboardListContainer';
+import { LeaderboardType } from '../../../graphql/leaderboard';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import loggedUser from '../../../../__tests__/fixture/loggedUser';
+import { useLeaderboardShareEnabled } from '../../../hooks/leaderboard/useLeaderboardShareEnabled';
+import { useViewSize } from '../../../hooks/useViewSize';
+import { getShortLinkProps } from '../../../hooks/utils/useGetShortUrl';
+import { ReferralCampaignKey } from '../../../lib/referral';
+
+// `NEXT_PUBLIC_WEBAPP_URL` is '/' under jest, but the referral tracker builds a
+// `new URL(...)` and needs the absolute origin production actually ships.
+jest.mock('../../../lib/constants', () => ({
+ ...jest.requireActual('../../../lib/constants'),
+ webappUrl: 'https://app.daily.dev/',
+}));
+
+jest.mock('../../../hooks/leaderboard/useLeaderboardShareEnabled', () => ({
+ useLeaderboardShareEnabled: jest.fn(),
+}));
+
+jest.mock('../../../hooks/useViewSize', () => {
+ const actual = jest.requireActual('../../../hooks/useViewSize');
+ return { __esModule: true, ...actual, useViewSize: jest.fn() };
+});
+
+const shareEnabledMock = useLeaderboardShareEnabled as jest.Mock;
+const viewSizeMock = useViewSize as jest.Mock;
+const writeText = jest.fn().mockResolvedValue(undefined);
+
+const link = 'https://app.daily.dev/users/longestStreak';
+const shortLink = 'https://dly.to/board';
+
+let client: QueryClient;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ shareEnabledMock.mockReturnValue(true);
+ viewSizeMock.mockReturnValue(true); // laptop
+ Object.assign(navigator, { clipboard: { writeText } });
+
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const { queryKey } = getShortLinkProps(
+ link,
+ ReferralCampaignKey.Generic,
+ loggedUser,
+ );
+ client.setQueryData(queryKey, shortLink);
+});
+
+const renderButton = (): RenderResult =>
+ render(
+
+
+ ,
+ );
+
+describe('LeaderboardShareButton', () => {
+ it('renders a labelled trigger naming the leaderboard', () => {
+ renderButton();
+
+ expect(
+ screen.getByLabelText('Share the longest streak leaderboard'),
+ ).toBeInTheDocument();
+ });
+
+ it('copies the leaderboard permalink', async () => {
+ renderButton();
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByLabelText('Share the longest streak leaderboard'),
+ );
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Copy link'));
+ });
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortLink));
+ });
+
+ it('renders nothing when the flag is off', () => {
+ shareEnabledMock.mockReturnValue(false);
+ const { container } = renderButton();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
+
+const renderContainer = (titleAction?: React.ReactNode): RenderResult =>
+ render(
+
+
+ row
+
+ ,
+ );
+
+describe('LeaderboardListContainer title action', () => {
+ it('keeps the plain heading markup when no title action is passed', () => {
+ renderContainer();
+
+ const heading = screen.getByRole('heading', { level: 3 });
+ expect(heading).toHaveClass('mb-2');
+ // No wrapper is inserted: the heading still sits directly above the list.
+ expect(heading.nextElementSibling?.tagName).toBe('OL');
+ });
+
+ it('lays the heading and the action out in one row when passed', () => {
+ renderContainer(
+ ,
+ );
+
+ const heading = screen.getByRole('heading', { level: 3 });
+ expect(heading).not.toHaveClass('mb-2');
+ expect(heading.parentElement).toHaveClass('justify-between');
+ expect(
+ screen.getByLabelText('Share the longest streak leaderboard'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.tsx
new file mode 100644
index 00000000000..d40a66f4de5
--- /dev/null
+++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.tsx
@@ -0,0 +1,64 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { ButtonSize } from '../../buttons/Button';
+import { ShareActions } from '../../share/ShareActions';
+import { useLogContext } from '../../../contexts/LogContext';
+import type { LeaderboardType } from '../../../graphql/leaderboard';
+import { leaderboardTypeToTitle } from '../../../graphql/leaderboard';
+import { useLeaderboardShareEnabled } from '../../../hooks/leaderboard/useLeaderboardShareEnabled';
+import { ReferralCampaignKey } from '../../../lib/referral';
+import {
+ getLeaderboardShareText,
+ getLeaderboardUrl,
+} from '../../../lib/leaderboardShare';
+import { LogEvent, Origin } from '../../../lib/log';
+
+interface LeaderboardShareControlProps {
+ type: LeaderboardType;
+ buttonSize?: ButtonSize;
+ className?: string;
+}
+
+// Presentation only — renders unconditionally so Storybook can show it without
+// a GrowthBook instance. Product surfaces should use `LeaderboardShareButton`.
+export function LeaderboardShareControl({
+ type,
+ buttonSize,
+ className,
+}: LeaderboardShareControlProps): ReactElement {
+ const { logEvent } = useLogContext();
+ const title = leaderboardTypeToTitle[type];
+ const text = getLeaderboardShareText(type);
+
+ return (
+
+ logEvent({
+ event_name: LogEvent.ShareLeaderboard,
+ target_id: type,
+ extra: JSON.stringify({ provider, origin: Origin.Leaderboard }),
+ })
+ }
+ />
+ );
+}
+
+export function LeaderboardShareButton(
+ props: LeaderboardShareControlProps,
+): ReactElement | null {
+ const isEnabled = useLeaderboardShareEnabled();
+
+ if (!isEnabled) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/packages/shared/src/components/cards/Leaderboard/common.ts b/packages/shared/src/components/cards/Leaderboard/common.ts
index ce4e541d6cb..522c3d0e6d5 100644
--- a/packages/shared/src/components/cards/Leaderboard/common.ts
+++ b/packages/shared/src/components/cards/Leaderboard/common.ts
@@ -8,6 +8,8 @@ export interface LeaderboardListContainerProps {
className?: string;
footer?: ReactNode;
header?: ReactNode;
+ /** Trailing control rendered next to the card title (e.g. a share button). */
+ titleAction?: ReactNode;
}
export const LeaderboardCard = classed(
diff --git a/packages/shared/src/hooks/leaderboard/useLeaderboardPosition.ts b/packages/shared/src/hooks/leaderboard/useLeaderboardPosition.ts
new file mode 100644
index 00000000000..a82ce09fbb2
--- /dev/null
+++ b/packages/shared/src/hooks/leaderboard/useLeaderboardPosition.ts
@@ -0,0 +1,45 @@
+import { useQuery } from '@tanstack/react-query';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { gqlClient } from '../../graphql/common';
+import type {
+ LeaderboardPosition,
+ LeaderboardType,
+} from '../../graphql/leaderboard';
+import { LEADERBOARD_POSITION_QUERY } from '../../graphql/leaderboard';
+import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query';
+
+interface UseLeaderboardPositionProps {
+ type: LeaderboardType;
+ enabled: boolean;
+}
+
+interface UseLeaderboardPosition {
+ position: LeaderboardPosition | null;
+ isPending: boolean;
+}
+
+// The API resolves the position for the *viewer*, so there is no user argument.
+// `position` is null both while disabled and when the backend has no row for
+// the viewer; callers must treat a null `position.rank` (viewer sits past
+// `cappedAt`) as "no rank" rather than rendering a number.
+export const useLeaderboardPosition = ({
+ type,
+ enabled,
+}: UseLeaderboardPositionProps): UseLeaderboardPosition => {
+ const { user } = useAuthContext();
+
+ const { data, isPending } = useQuery({
+ queryKey: generateQueryKey(RequestKey.LeaderboardPosition, user, type),
+ queryFn: async () => {
+ const res = await gqlClient.request<{
+ leaderboardPosition: LeaderboardPosition | null;
+ }>(LEADERBOARD_POSITION_QUERY, { type });
+
+ return res.leaderboardPosition;
+ },
+ enabled,
+ staleTime: StaleTime.Default,
+ });
+
+ return { position: data ?? null, isPending: enabled && isPending };
+};
diff --git a/packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.spec.tsx b/packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.spec.tsx
new file mode 100644
index 00000000000..a23c5b76e92
--- /dev/null
+++ b/packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.spec.tsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react';
+import { QueryClient } from '@tanstack/react-query';
+import { useLeaderboardShareEnabled } from './useLeaderboardShareEnabled';
+import { useConditionalFeature } from '../useConditionalFeature';
+import type { Feature } from '../../lib/featureManagement';
+import {
+ featureSharingVisibility,
+ featureShareLeaderboard,
+} from '../../lib/featureManagement';
+import { TestBootProvider } from '../../../__tests__/helpers/boot';
+
+jest.mock('../useConditionalFeature', () => ({
+ useConditionalFeature: jest.fn(),
+}));
+
+const conditionalFeatureMock = useConditionalFeature as jest.Mock;
+const evaluated: string[] = [];
+
+const mockFlags = (flags: Record) =>
+ conditionalFeatureMock.mockImplementation(
+ ({
+ feature,
+ shouldEvaluate,
+ }: {
+ feature: Feature;
+ shouldEvaluate?: boolean;
+ }) => {
+ if (shouldEvaluate !== false) {
+ evaluated.push(feature.id);
+ }
+
+ return {
+ value:
+ shouldEvaluate === false ? feature.defaultValue : flags[feature.id],
+ isLoading: false,
+ };
+ },
+ );
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ evaluated.length = 0;
+});
+
+describe('useLeaderboardShareEnabled', () => {
+ it('is off while the sharing-visibility kill switch is off', () => {
+ mockFlags({
+ [featureSharingVisibility.id]: false,
+ [featureShareLeaderboard.id]: true,
+ });
+
+ const { result } = renderHook(() => useLeaderboardShareEnabled(), {
+ wrapper,
+ });
+
+ expect(result.current).toBe(false);
+ // The per-topic flag must not be evaluated for control users.
+ expect(evaluated).not.toContain(featureShareLeaderboard.id);
+ });
+
+ it('is off when the per-topic flag is off', () => {
+ mockFlags({
+ [featureSharingVisibility.id]: true,
+ [featureShareLeaderboard.id]: false,
+ });
+
+ const { result } = renderHook(() => useLeaderboardShareEnabled(), {
+ wrapper,
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('is on only when both flags are on', () => {
+ mockFlags({
+ [featureSharingVisibility.id]: true,
+ [featureShareLeaderboard.id]: true,
+ });
+
+ const { result } = renderHook(() => useLeaderboardShareEnabled(), {
+ wrapper,
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('never evaluates anything when the caller opts out', () => {
+ mockFlags({
+ [featureSharingVisibility.id]: true,
+ [featureShareLeaderboard.id]: true,
+ });
+
+ const { result } = renderHook(() => useLeaderboardShareEnabled(false), {
+ wrapper,
+ });
+
+ expect(result.current).toBe(false);
+ expect(evaluated).toHaveLength(0);
+ });
+});
diff --git a/packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.ts b/packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.ts
new file mode 100644
index 00000000000..2738ce28e88
--- /dev/null
+++ b/packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.ts
@@ -0,0 +1,17 @@
+import { featureShareLeaderboard } from '../../lib/featureManagement';
+import { useConditionalFeature } from '../useConditionalFeature';
+import { useSharingVisibility } from '../useSharingVisibility';
+
+// Gate for every leaderboard share affordance (directory cards, leaderboard
+// page header, personal rank card). The per-topic flag is only evaluated once
+// the sharing-visibility master switch is on, so control users never get
+// bucketed into the experiment.
+export const useLeaderboardShareEnabled = (shouldEvaluate = true): boolean => {
+ const { isEnabled: isSharingEnabled } = useSharingVisibility(shouldEvaluate);
+ const { value } = useConditionalFeature({
+ feature: featureShareLeaderboard,
+ shouldEvaluate: isSharingEnabled,
+ });
+
+ return isSharingEnabled && value;
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 97b38cbdd2b..2fdc26fd4d3 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -308,3 +308,9 @@ export const featureSharingVisibility = new Feature(
// high-traffic icon, so it ramps on its own flag to watch share/copy metrics.
// Keep the default `false` (control = existing `LinkIcon`).
export const featureShareCopyIcon = new Feature('share_copy_icon', false);
+
+// Adds share affordances to the leaderboard surfaces: a per-leaderboard share
+// on the directory cards, a page-level share on a leaderboard page, and the
+// personal "share my rank" card. Gated together with the sharing-visibility
+// master switch. Keep the default `false` — GrowthBook ramps it.
+export const featureShareLeaderboard = new Feature('share_leaderboard', false);
diff --git a/packages/shared/src/lib/leaderboardShare.ts b/packages/shared/src/lib/leaderboardShare.ts
new file mode 100644
index 00000000000..ca024f2ae6e
--- /dev/null
+++ b/packages/shared/src/lib/leaderboardShare.ts
@@ -0,0 +1,32 @@
+import type { LeaderboardType } from '../graphql/leaderboard';
+import { leaderboardTypeToTitle } from '../graphql/leaderboard';
+import { webappUrl } from './constants';
+
+// A leaderboard's public permalink — the same URL the directory card links to,
+// so a shared rank always lands on the board the rank was earned on.
+export const getLeaderboardUrl = (type: LeaderboardType): string =>
+ `${webappUrl}users/${type}`;
+
+export const getLeaderboardShareText = (type: LeaderboardType): string => {
+ const title = leaderboardTypeToTitle[type].toLowerCase();
+
+ return `Top developers by ${title} on daily.dev`;
+};
+
+const TOP_TIER_RANK = 3;
+
+// Pre-filled text for "share my rank". Deliberately understated — a share sheet
+// full of superlatives reads like a template, not like a developer.
+export const getLeaderboardRankShareText = (
+ type: LeaderboardType,
+ rank: number,
+): string => {
+ const title = leaderboardTypeToTitle[type].toLowerCase();
+ const board = `the daily.dev ${title} leaderboard`;
+
+ if (rank <= TOP_TIER_RANK) {
+ return `Somehow I'm #${rank} on ${board}. Come take the spot.`;
+ }
+
+ return `I'm #${rank} on ${board}. Still climbing.`;
+};
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 9696707fa61..9b65d25499d 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -531,6 +531,8 @@ export enum LogEvent {
SubmitGivebackCauseSuggestionError = 'submit giveback cause suggestion error',
// Daily homepage
DailyFeedback = 'daily feedback',
+ // Sharing visibility — leaderboard (rank shares carry `rank` in extra)
+ ShareLeaderboard = 'share leaderboard',
}
export enum TargetType {
diff --git a/packages/storybook/stories/components/LeaderboardShare.stories.tsx b/packages/storybook/stories/components/LeaderboardShare.stories.tsx
new file mode 100644
index 00000000000..b41cd7b96e9
--- /dev/null
+++ b/packages/storybook/stories/components/LeaderboardShare.stories.tsx
@@ -0,0 +1,202 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ LeaderboardRankCard,
+ LeaderboardRankShare,
+} from '@dailydotdev/shared/src/components/cards/Leaderboard/LeaderboardRankShare';
+import { LeaderboardShareControl } from '@dailydotdev/shared/src/components/cards/Leaderboard/LeaderboardShareButton';
+import { UserTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard/UserTopList';
+import { FeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider';
+import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext';
+import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
+import { LeaderboardType } from '@dailydotdev/shared/src/graphql/leaderboard';
+import type { LoggedUser } from '@dailydotdev/shared/src/lib/user';
+import { fn } from 'storybook/test';
+
+const mockUser = {
+ id: '1',
+ name: 'Ada Lovelace',
+ username: 'ada',
+ email: 'ada@example.com',
+ image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg',
+ providers: ['google'],
+ createdAt: '2024-01-01T00:00:00.000Z',
+ permalink: 'https://daily.dev/ada',
+ reputation: 420,
+} as unknown as LoggedUser;
+
+/**
+ * The repo-wide GrowthBook storybook mock returns `value || 'control'`, so a
+ * `false` default reads back as the truthy string 'control' and every flag
+ * looks ON. These stories therefore never assert a flag: the ON states render
+ * the presentational components directly, and the control state holds
+ * `FeaturesReadyContext` as not-ready, which is the one reliable way to force
+ * `useConditionalFeature` back to the default here. Jest owns the real
+ * flag-off guarantee (`useLeaderboardShareEnabled.spec.tsx`).
+ */
+const withProviders =
+ ({ featuresReady = true }: { featuresReady?: boolean } = {}) =>
+ (Story: React.ComponentType): React.ReactElement => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ const LogContext = getLogContextStatic();
+
+ return (
+
+
+ feature.defaultValue as never,
+ }}
+ >
+ false,
+ }}
+ >
+
+
+
+
+
+
+
+ );
+ };
+
+const Note = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+const meta: Meta = {
+ title: 'Components/Share/LeaderboardShare',
+ component: LeaderboardRankCard,
+ args: { type: LeaderboardType.LongestStreak, rank: 12 },
+ argTypes: {
+ type: {
+ control: 'select',
+ options: Object.values(LeaderboardType),
+ },
+ rank: { control: { type: 'number', min: 1 } },
+ },
+ decorators: [withProviders()],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+// The headline affordance: the viewer's own rank, with the exact text that
+// will be shared spelled out underneath it.
+export const OwnRank: Story = {};
+
+// Top three earns the medal badge and the cheekier line.
+export const OwnRankTopThree: Story = {
+ args: { rank: 2 },
+};
+
+// Four figures — the rank stays legible thanks to tabular numerals.
+export const OwnRankDeep: Story = {
+ args: { rank: 1487 },
+};
+
+export const RankShareByLeaderboard: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+};
+
+// Unranked / still loading / control all resolve to the same thing: nothing.
+// `LeaderboardRankShare` is the gated component, so with features held
+// not-ready it renders the control variant.
+export const NoRankOrFlagOff: Story = {
+ render: () => (
+
+
+
+ Unranked (rank past the cap), still loading, or flag off — the rank card
+ renders nothing and the page keeps its current layout.
+
+
+ ),
+ decorators: [withProviders({ featuresReady: false })],
+};
+
+const leaderboardItems = [
+ { score: 96, user: { ...mockUser, id: '1', username: 'ada' } },
+ { score: 74, user: { ...mockUser, id: '2', username: 'grace' } },
+ { score: 51, user: { ...mockUser, id: '3', username: 'linus' } },
+];
+
+// Directory surface: each leaderboard card on /users gets its own share
+// control next to the card title.
+export const DirectoryCard: StoryObj = {
+ render: () => (
+
+
+ ),
+ }}
+ items={leaderboardItems}
+ isLoading={false}
+ concatScore={false}
+ />
+
+
+ Top: sharing on. Bottom: control — no title action is passed, so the
+ heading keeps its original markup.
+
+
+ ),
+};
diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx
index e2ec4052ba8..81f3c8ed3a1 100644
--- a/packages/webapp/pages/users.tsx
+++ b/packages/webapp/pages/users.tsx
@@ -24,6 +24,8 @@ import { CompanyTopList } from '@dailydotdev/shared/src/components/cards/Leaderb
import type { PopularHotTakes } from '@dailydotdev/shared/src/components/cards/Leaderboard/PopularHotTakesList';
import { PopularHotTakesList } from '@dailydotdev/shared/src/components/cards/Leaderboard/PopularHotTakesList';
import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner';
+import { LeaderboardShareButton } from '@dailydotdev/shared/src/components/cards/Leaderboard/LeaderboardShareButton';
+import { useLeaderboardShareEnabled } from '@dailydotdev/shared/src/hooks/leaderboard/useLeaderboardShareEnabled';
import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout';
import { getLayout } from '../components/layouts/MainLayout';
import { defaultOpenGraph } from '../next-seo';
@@ -85,6 +87,11 @@ const LeaderboardPage = ({
const { isFallback: isLoading } = useRouter();
const { isV2 } = useLayoutVariant();
const isV2Laptop = isV2;
+ const isShareEnabled = useLeaderboardShareEnabled();
+ // Only hand the card a title action when sharing is on, so the control
+ // variant keeps the plain `` heading instead of an empty flex wrapper.
+ const shareAction = (type: LeaderboardType) =>
+ isShareEnabled ? : undefined;
if (isLoading) {
return <>>;
@@ -107,6 +114,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Highest level',
titleHref: `/users/${LeaderboardType.HighestLevel}`,
+ titleAction: shareAction(LeaderboardType.HighestLevel),
}}
items={highestLevel}
isLoading={isLoading}
@@ -117,6 +125,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Highest reputation',
titleHref: `/users/${LeaderboardType.HighestReputation}`,
+ titleAction: shareAction(LeaderboardType.HighestReputation),
}}
items={highestReputation}
isLoading={isLoading}
@@ -126,6 +135,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Longest streak',
titleHref: `/users/${LeaderboardType.LongestStreak}`,
+ titleAction: shareAction(LeaderboardType.LongestStreak),
}}
items={longestStreak}
isLoading={isLoading}
@@ -136,6 +146,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Highest post views',
titleHref: `/users/${LeaderboardType.HighestPostViews}`,
+ titleAction: shareAction(LeaderboardType.HighestPostViews),
}}
items={highestPostViews}
isLoading={isLoading}
@@ -144,6 +155,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Most upvoted',
titleHref: `/users/${LeaderboardType.MostUpvoted}`,
+ titleAction: shareAction(LeaderboardType.MostUpvoted),
}}
items={mostUpvoted}
isLoading={isLoading}
@@ -152,6 +164,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Most referrals',
titleHref: `/users/${LeaderboardType.MostReferrals}`,
+ titleAction: shareAction(LeaderboardType.MostReferrals),
}}
items={mostReferrals}
isLoading={isLoading}
@@ -160,6 +173,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Most reading days',
titleHref: `/users/${LeaderboardType.MostReadingDays}`,
+ titleAction: shareAction(LeaderboardType.MostReadingDays),
}}
items={mostReadingDays}
isLoading={isLoading}
@@ -169,6 +183,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Most achievement points',
titleHref: `/users/${LeaderboardType.MostAchievementPoints}`,
+ titleAction: shareAction(LeaderboardType.MostAchievementPoints),
}}
items={mostAchievementPoints}
isLoading={isLoading}
@@ -177,6 +192,7 @@ const LeaderboardPage = ({
containerProps={{
title: 'Most verified employees',
titleHref: `/users/${LeaderboardType.MostVerifiedUsers}`,
+ titleAction: shareAction(LeaderboardType.MostVerifiedUsers),
}}
items={mostVerifiedUsers}
isLoading={isLoading}
diff --git a/packages/webapp/pages/users/[id].tsx b/packages/webapp/pages/users/[id].tsx
index bb091528c5a..fb8563f2390 100644
--- a/packages/webapp/pages/users/[id].tsx
+++ b/packages/webapp/pages/users/[id].tsx
@@ -31,6 +31,9 @@ import {
} from '@dailydotdev/shared/src/components/buttons/Button';
import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant';
import Link from '@dailydotdev/shared/src/components/utilities/Link';
+import { LeaderboardShareButton } from '@dailydotdev/shared/src/components/cards/Leaderboard/LeaderboardShareButton';
+import { LeaderboardRankShare } from '@dailydotdev/shared/src/components/cards/Leaderboard/LeaderboardRankShare';
+import { useLeaderboardShareEnabled } from '@dailydotdev/shared/src/hooks/leaderboard/useLeaderboardShareEnabled';
import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout';
import { getLayout } from '../../components/layouts/MainLayout';
import { defaultOpenGraph } from '../../next-seo';
@@ -72,6 +75,12 @@ const LeaderboardDetailPage = ({
const isCompany = isCompanyLeaderboard(leaderboardType);
const isLevelLeaderboard = leaderboardType === LeaderboardType.HighestLevel;
const concatScore = leaderboardType !== LeaderboardType.LongestStreak;
+ const isShareEnabled = useLeaderboardShareEnabled();
+ // Rendered as `undefined` when sharing is off so the header/breadcrumb row
+ // keeps its control-variant DOM instead of an empty actions wrapper.
+ const shareButton = isShareEnabled ? (
+
+ ) : undefined;
if (isLoading || !title) {
return <>>;
@@ -97,7 +106,9 @@ const LeaderboardDetailPage = ({
}
- />
+ >
+ {shareButton}
+
)}
{!isV2Laptop && (
@@ -110,9 +121,11 @@ const LeaderboardDetailPage = ({
/
{title}
+ {shareButton}
)}
+ {!isCompany && }
{isCompany ? (