From c8640232a4b35845c798ee63a57b74e16ba7ebda Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 17:56:03 +0300 Subject: [PATCH 1/2] feat(share): leaderboard share affordances + share-my-rank card Adds sharing to the leaderboard surfaces behind `share_leaderboard`, gated together with the `sharing_visibility` master switch. - Personal rank share on a leaderboard page: "You're #12 on the longest streak leaderboard" with the pre-filled share text spelled out, sourced from the existing viewer-scoped `leaderboardPosition(type)` query. - Per-leaderboard share on every directory card, via a new optional `titleAction` slot on `LeaderboardListContainer`. - Page-level share on a leaderboard page (v2 header + breadcrumb row). `useLeaderboardPosition` extracts the query that `CurrentUserPositionRow` already ran, so both surfaces share one cache entry. Co-Authored-By: Claude Opus 4.8 --- .../Leaderboard/CurrentUserPositionRow.tsx | 35 +-- .../Leaderboard/LeaderboardListContainer.tsx | 53 +++-- .../Leaderboard/LeaderboardRankShare.spec.tsx | 183 ++++++++++++++++ .../Leaderboard/LeaderboardRankShare.tsx | 128 +++++++++++ .../LeaderboardShareButton.spec.tsx | 136 ++++++++++++ .../Leaderboard/LeaderboardShareButton.tsx | 64 ++++++ .../components/cards/Leaderboard/common.ts | 2 + .../leaderboard/useLeaderboardPosition.ts | 45 ++++ .../useLeaderboardShareEnabled.spec.tsx | 105 +++++++++ .../leaderboard/useLeaderboardShareEnabled.ts | 17 ++ packages/shared/src/lib/featureManagement.ts | 6 + packages/shared/src/lib/leaderboardShare.ts | 32 +++ packages/shared/src/lib/log.ts | 3 + .../components/LeaderboardShare.stories.tsx | 202 ++++++++++++++++++ packages/webapp/pages/users.tsx | 16 ++ packages/webapp/pages/users/[id].tsx | 15 +- 16 files changed, 998 insertions(+), 44 deletions(-) create mode 100644 packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.spec.tsx create mode 100644 packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx create mode 100644 packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.spec.tsx create mode 100644 packages/shared/src/components/cards/Leaderboard/LeaderboardShareButton.tsx create mode 100644 packages/shared/src/hooks/leaderboard/useLeaderboardPosition.ts create mode 100644 packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.spec.tsx create mode 100644 packages/shared/src/hooks/leaderboard/useLeaderboardShareEnabled.ts create mode 100644 packages/shared/src/lib/leaderboardShare.ts create mode 100644 packages/storybook/stories/components/LeaderboardShare.stories.tsx 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 ?? ( -

    - {titleHref ? ( - - - {title} - {titleHref && ( - - )} - - - ) : ( - <>{title} - )} -

    - )} + {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..b17874d8d6b --- /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.ShareLeaderboardRank, + 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..d29803df37f 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -531,6 +531,9 @@ export enum LogEvent { SubmitGivebackCauseSuggestionError = 'submit giveback cause suggestion error', // Daily homepage DailyFeedback = 'daily feedback', + // Sharing visibility — leaderboards + ShareLeaderboard = 'share leaderboard', + ShareLeaderboardRank = 'share leaderboard rank', } 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 ? ( Date: Wed, 22 Jul 2026 18:14:07 +0300 Subject: [PATCH 2/2] refactor(share): collapse rank share into ShareLeaderboard event Initiative-wide normalization: one event per entity, surfaces distinguished by payload. Rank shares already carry rank in extra, so the dedicated ShareLeaderboardRank event was redundant. Co-Authored-By: Claude Opus 4.8 --- .../src/components/cards/Leaderboard/LeaderboardRankShare.tsx | 2 +- packages/shared/src/lib/log.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx index b17874d8d6b..090b0929741 100644 --- a/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx +++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardRankShare.tsx @@ -77,7 +77,7 @@ export function LeaderboardRankCard({ label="Share your rank" onShare={(provider) => logEvent({ - event_name: LogEvent.ShareLeaderboardRank, + event_name: LogEvent.ShareLeaderboard, target_id: type, extra: JSON.stringify({ provider, diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index d29803df37f..9b65d25499d 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -531,9 +531,8 @@ export enum LogEvent { SubmitGivebackCauseSuggestionError = 'submit giveback cause suggestion error', // Daily homepage DailyFeedback = 'daily feedback', - // Sharing visibility — leaderboards + // Sharing visibility — leaderboard (rank shares carry `rank` in extra) ShareLeaderboard = 'share leaderboard', - ShareLeaderboardRank = 'share leaderboard rank', } export enum TargetType {