diff --git a/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx b/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx index 47f46b2d5d..e18609961f 100644 --- a/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import type { CommonLeaderboardProps } from './LeaderboardList'; import { LeaderboardList } from './LeaderboardList'; import { LeaderboardListItem } from './LeaderboardListItem'; -import type { Company } from '../../../lib/userCompany'; +import type { CompanyLeaderboard } from '../../../graphql/leaderboard'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { runIconPopAnimation, @@ -13,10 +13,7 @@ import { } from './common'; import { TopRankBadge } from './TopRankBadge'; -export interface CompanyLeaderboard { - score: number; - company: Company; -} +export type { CompanyLeaderboard }; export function CompanyTopList({ items, diff --git a/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx b/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx index 99463707bd..1b345154ae 100644 --- a/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx +++ b/packages/shared/src/components/cards/Leaderboard/CurrentUserPositionRow.tsx @@ -2,16 +2,11 @@ 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 type { LeaderboardType } from '../../../graphql/leaderboard'; import { isLeaderboardPositionSupported, - LEADERBOARD_POSITION_QUERY, + leaderboardPositionQueryOptions, } from '../../../graphql/leaderboard'; -import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import { largeNumberFormat } from '../../../lib'; import { UserHighlight } from '../../widgets/PostUsersHighlights'; @@ -32,16 +27,8 @@ export function CurrentUserPositionRow({ 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 }); - - return res.leaderboardPosition; - }, + ...leaderboardPositionQueryOptions(user, type), enabled, - staleTime: StaleTime.Default, }); if (!enabled || !data) { diff --git a/packages/shared/src/components/cards/Leaderboard/UserTopList.spec.tsx b/packages/shared/src/components/cards/Leaderboard/UserTopList.spec.tsx index 96c7979ca4..1198a412fd 100644 --- a/packages/shared/src/components/cards/Leaderboard/UserTopList.spec.tsx +++ b/packages/shared/src/components/cards/Leaderboard/UserTopList.spec.tsx @@ -23,6 +23,7 @@ const makeItem = (level?: { id: 'user-1', username: 'user-1', permalink: '/users/user-1', + reputation: 10, }, level, }); diff --git a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx index fe32e0269b..474a5cc0cb 100644 --- a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx @@ -8,9 +8,10 @@ import { LeaderboardRowPlaceholder } from './LeaderboardRowPlaceholder'; import { ElementPlaceholder } from '../../ElementPlaceholder'; import { CurrentUserPositionRow } from './CurrentUserPositionRow'; import { UserHighlight } from '../../widgets/PostUsersHighlights'; -import type { LoggedUser } from '../../../lib/user'; -import type { QuestLevel } from '../../../graphql/quests'; -import type { LeaderboardType } from '../../../graphql/leaderboard'; +import type { + LeaderboardType, + UserLeaderboard, +} from '../../../graphql/leaderboard'; import { runIconPopAnimation, runSparkAnimation, @@ -22,11 +23,7 @@ import { QuestLevelProgressCircle, } from '../../quest/QuestLevelProgressCircle'; -export interface UserLeaderboard { - score: number; - user: LoggedUser; - level?: QuestLevel | null; -} +export type { UserLeaderboard }; export function UserTopList({ items, diff --git a/packages/shared/src/features/leaderboard/LeaderboardActionCard.tsx b/packages/shared/src/features/leaderboard/LeaderboardActionCard.tsx new file mode 100644 index 0000000000..005b3cbe89 --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardActionCard.tsx @@ -0,0 +1,57 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +/** + * One card on the page's first row: what it is in a line, one detail, and + * one button that keeps its size and place on every card. Below laptop the + * button, and whatever sits beside it, fill the card's width. + */ +export const LeaderboardActionCard = ({ + title, + detail, + children, + action, + aside, + className, + actionClassName, +}: { + title: ReactNode; + detail?: ReactNode; + children?: ReactNode; + action?: ReactNode; + aside?: ReactNode; + className?: string; + /** Where the action shows, when a card moves it on some widths. */ + actionClassName?: string; +}): ReactElement => ( +
+
+
{title}
+ {detail && ( +
{detail}
+ )} +
+ {children} + {(aside || action) && ( +
+ {aside} + {action && ( + + {action} + + )} +
+ )} +
+); diff --git a/packages/shared/src/features/leaderboard/LeaderboardBoardTile.spec.tsx b/packages/shared/src/features/leaderboard/LeaderboardBoardTile.spec.tsx new file mode 100644 index 0000000000..093480c1ed --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardBoardTile.spec.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import nock from 'nock'; +import { QueryClient } from '@tanstack/react-query'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../__tests__/helpers/graphql'; +import { mockDesktop } from '../../../__tests__/helpers/media'; +import type { UserLeaderboard } from '../../graphql/leaderboard'; +import { + HIGHEST_POST_VIEWS_QUERY, + LEADERBOARD_LIST_LIMIT, + LEADERBOARD_TOP_LIMIT, + LeaderboardType, +} from '../../graphql/leaderboard'; +import { LogEvent, TargetType } from '../../lib/log'; +import { LeaderboardUserBoardTile } from './LeaderboardUserBoardTile'; + +const logEvent = jest.fn(); + +const makeItems = (count: number, prefix = 'Dev'): UserLeaderboard[] => + Array.from({ length: count }, (_, index) => ({ + score: 1000 - index, + user: { + id: `${prefix}-${index}`, + username: `${prefix}-${index}`, + name: `${prefix} ${index}`, + image: '', + permalink: `/${prefix}-${index}`, + createdAt: new Date().toISOString(), + bio: '', + reputation: 10, + }, + })); + +const createClient = () => + new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const renderTile = (items: UserLeaderboard[]) => + render( + + + , + ); + +beforeEach(() => { + nock.cleanAll(); + mockDesktop(); + logEvent.mockClear(); +}); + +describe('LeaderboardBoardTile', () => { + it('should show the top rows and ask for the full list only once opened', async () => { + const items = makeItems(LEADERBOARD_LIST_LIMIT); + renderTile(items); + + expect(screen.getByText('Dev 0').closest('a')).toHaveAttribute( + 'href', + '/Dev-0', + ); + expect(screen.getByText('Dev 3')).toBeInTheDocument(); + expect(screen.queryByText('Dev 4')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + mockGraphQL({ + request: { + query: HIGHEST_POST_VIEWS_QUERY, + variables: { limit: LEADERBOARD_TOP_LIMIT }, + }, + result: { data: { highestPostViews: makeItems(20, 'Top') } }, + }); + + const cue = screen.getByRole('link', { + name: 'Highest post views, open top 50', + }); + expect(cue).toHaveAttribute('href', '/users/highestPostViews'); + fireEvent.click(cue); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveAccessibleName('Highest post views · Top 50'); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LeaderboardBoard, + target_id: LeaderboardType.HighestPostViews, + extra: JSON.stringify({ surface: 'popover' }), + }); + await screen.findByText('Top 19'); + expect(nock.isDone()).toBe(true); + expect(screen.getByText('Top 19').closest('a')).toHaveAttribute( + 'href', + '/Top-19', + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + }); + + it('should fall back to the page rows with a retry when the list fails', async () => { + renderTile(makeItems(LEADERBOARD_LIST_LIMIT)); + mockGraphQL({ + request: { + query: HIGHEST_POST_VIEWS_QUERY, + variables: { limit: LEADERBOARD_TOP_LIMIT }, + }, + result: { errors: [{ message: 'boom' } as never] }, + }); + + fireEvent.click( + screen.getByRole('link', { name: 'Highest post views, open top 50' }), + ); + + await screen.findByText('Couldn’t load the full list.'); + const dialog = screen.getByRole('dialog'); + expect(dialog).not.toHaveAttribute('aria-busy'); + expect(within(dialog).getByText('Dev 11')).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Retry' }), + ).toBeInTheDocument(); + }); + + it('should say how many a short board has and not open an empty one', () => { + const { rerender } = renderTile(makeItems(5)); + + expect(screen.getByText('All 5')).toBeInTheDocument(); + + rerender( + + + , + ); + + expect( + screen.getByText('Nothing here right now. Check back soon.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/leaderboard/LeaderboardBoardTile.tsx b/packages/shared/src/features/leaderboard/LeaderboardBoardTile.tsx new file mode 100644 index 0000000000..270a48cc74 --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardBoardTile.tsx @@ -0,0 +1,273 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '../../components/buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; +import { Drawer } from '../../components/drawers/Drawer'; +import { MiniCloseIcon } from '../../components/icons/MiniClose'; +import { RootPortal } from '../../components/tooltips/Portal'; +import type { + CompanyLeaderboard, + LeaderboardType, + UserLeaderboard, +} from '../../graphql/leaderboard'; +import { + LEADERBOARD_LIST_LIMIT, + LEADERBOARD_TOP_LIMIT, + leaderboardBoardQueryOptions, +} from '../../graphql/leaderboard'; +import { useIsPhone } from '../../hooks/useViewSize'; +import { useLogContext } from '../../contexts/LogContext'; +import { leaderboardBoardClickEvent } from './leaderboardLog'; +import { LEADERBOARD_TILE_ROWS } from './boards'; +import { + LeaderboardEmptyBoard, + LeaderboardRowPlaceholder, + LeaderboardTextLink, +} from './LeaderboardRows'; + +const EXPAND_MS = 220; + +export interface LeaderboardBoardTileProps< + T extends UserLeaderboard | CompanyLeaderboard, +> { + type: LeaderboardType; + title: string; + /** The page's rows for the board, up to LEADERBOARD_LIST_LIMIT. */ + items: T[]; + renderRow: (item: T, rank: number) => ReactNode; + /** Your own row, pinned under the top rows and under the full list. */ + you?: ReactNode; +} + +/** + * A board's top rows and, where production can rank you, your row. The + * header's cue is the board page's link; a press on it opens the top 50 in + * place instead: as a popover from tablet up, in a bottom sheet on phones. + * The rows stay links of their own, so people and crawlers reach the + * profiles from here. The full list is only asked for once it opens. + */ +export function LeaderboardBoardTile< + T extends UserLeaderboard | CompanyLeaderboard, +>({ + type, + title, + items, + renderRow, + you, +}: LeaderboardBoardTileProps): ReactElement { + const isPhone = useIsPhone(); + const { logEvent } = useLogContext(); + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + const cue = useRef(null); + const isPopover = open && !isPhone; + const { + data: all, + isPending, + isError, + isFetching, + refetch, + } = useQuery({ + ...leaderboardBoardQueryOptions(type, LEADERBOARD_TOP_LIMIT), + enabled: open, + }); + const openList = () => { + setOpen(true); + logEvent(leaderboardBoardClickEvent(type, isPhone ? 'sheet' : 'popover')); + }; + const close = () => { + setOpen(false); + cue.current?.querySelector('a')?.focus(); + }; + + // The popover grows out of the tile: mounted first at the tile's size, + // then its full size a frame later so the transition has somewhere to go. + useEffect(() => { + if (!isPopover) { + setExpanded(false); + return undefined; + } + const frame = requestAnimationFrame(() => setExpanded(true)); + return () => cancelAnimationFrame(frame); + }, [isPopover]); + + // Escape closes the popover, like the modals; the scrim takes the presses + // beside it. Portaled hover cards sit above the scrim, so a press in one + // keeps the popover up. + useEffect(() => { + if (!isPopover) { + return undefined; + } + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + close(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + // close only reads refs + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPopover]); + + const empty = items.length === 0; + // The failed fetch falls back to the page's own rows. + const list = all ?? (isError ? items : undefined); + // A board shorter than the list's limit says how many it has instead. The + // page's rows only tell once they run short of their own limit. + const known = all ?? (items.length < LEADERBOARD_LIST_LIMIT ? items : null); + const listLabel = + known && known.length < LEADERBOARD_TOP_LIMIT + ? `All ${known.length}` + : `Top ${LEADERBOARD_TOP_LIMIT}`; + const sheetTitle = `${title} · ${listLabel}`; + const rows = (entries: T[]) => + entries.map((item, index) => renderRow(item, index + 1)); + const allRows = ( + <> + {isError && !all && ( +
  • + Couldn’t load the full list. + +
  • + )} + {list + ? rows(list) + : Array.from({ length: LEADERBOARD_LIST_LIMIT }, (_, index) => ( + + ))} + + ); + const topRows = rows( + items.slice(0, you ? LEADERBOARD_TILE_ROWS - 1 : LEADERBOARD_TILE_ROWS), + ); + + return ( +
    +
    +
    + {title} + {!empty && ( + + { + // A plain press opens the list here; a modifier or middle + // press keeps the board page's link. + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + openList(); + }} + > + {listLabel} + + + )} +
    + {empty ? ( + + ) : ( +
      + {topRows} + {you && ( + <> +
    1. + {you} + + )} +
    + )} +
    + {isPopover && ( + <> + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} +
    +
    +
    + + {title} + + {listLabel} + + +
    +
      + {allRows} +
    + {you && ( +
      + {you} +
    + )} +
    + + )} + {isPhone && ( + + setOpen(false)} + title={sheetTitle} + className={{ drawer: 'py-1' }} + > +
      + {allRows} +
    + {you && ( +
      + {you} +
    + )} +
    +
    + )} +
    + ); +} diff --git a/packages/shared/src/features/leaderboard/LeaderboardCompanyTile.tsx b/packages/shared/src/features/leaderboard/LeaderboardCompanyTile.tsx new file mode 100644 index 0000000000..a0b8ed9a7e --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardCompanyTile.tsx @@ -0,0 +1,61 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../components/ProfilePicture'; +import { useAuthContext } from '../../contexts/AuthContext'; +import type { CompanyLeaderboard } from '../../graphql/leaderboard'; +import { + LeaderboardType, + leaderboardTypeToTitle, +} from '../../graphql/leaderboard'; +import { formatLeaderboardScore, LEADERBOARD_TILE_ROWS } from './boards'; +import { LeaderboardBoardTile } from './LeaderboardBoardTile'; +import { LeaderboardMiniRow } from './LeaderboardRows'; + +interface LeaderboardCompanyTileProps { + items: CompanyLeaderboard[]; +} + +/** + * Most verified employees: companies only, so there is no row for you. Your + * verified company is lit wherever it is on the board, and pinned under the + * top rows when it is further down. + */ +export const LeaderboardCompanyTile = ({ + items, +}: LeaderboardCompanyTileProps): ReactElement => { + const { user } = useAuthContext(); + const mine = items.findIndex((item) => + user?.companies?.some((company) => company.name === item.company.name), + ); + const row = (item: CompanyLeaderboard, rank: number) => ( + + } + value={formatLeaderboardScore(item.score)} + highlight={rank - 1 === mine} + /> + ); + + return ( + = LEADERBOARD_TILE_ROWS ? row(items[mine], mine + 1) : undefined + } + /> + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardHub.tsx b/packages/shared/src/features/leaderboard/LeaderboardHub.tsx new file mode 100644 index 0000000000..e8b04b7a5c --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardHub.tsx @@ -0,0 +1,103 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import { useAuthContext } from '../../contexts/AuthContext'; +import type { + CompanyLeaderboard, + UserLeaderboard, +} from '../../graphql/leaderboard'; +import { LeaderboardType } from '../../graphql/leaderboard'; +import { useReferralLadder } from '../../hooks/referral/useReferralLadder'; +import { LEADERBOARD_BOARDS } from './boards'; +import { LeaderboardCompanyTile } from './LeaderboardCompanyTile'; +import { LeaderboardInviteCard } from './LeaderboardInviteCard'; +import { LeaderboardRankCard } from './LeaderboardRankCard'; +import { LeaderboardHeading } from './LeaderboardRows'; +import { LeaderboardStandouts } from './LeaderboardStandouts'; +import { LeaderboardTopReputation } from './LeaderboardTopReputation'; +import { LeaderboardUserBoardTile } from './LeaderboardUserBoardTile'; + +export type LeaderboardBoards = Partial< + Record +>; + +export interface LeaderboardHubProps { + /** Each people board's top rows; a board the API lacks is left out. */ + boards: LeaderboardBoards; + companies: CompanyLeaderboard[]; +} + +const noItems: UserLeaderboard[] = []; + +/** Signed in, the page opens on you: your rank and the Plus invite. */ +const ActionRow = ({ + getBoard, +}: { + getBoard: (type: LeaderboardType) => UserLeaderboard[]; +}): ReactElement | null => { + const { user } = useAuthContext(); + const { isEligible, isLoading } = useReferralLadder(); + const hasInvite = isEligible || isLoading; + + if (!user) { + return null; + } + + return ( + // Phones: one strip you scroll sideways, the Plus card peeking in at the + // edge so it reads as more to see. +
    +
    + +
    + {hasInvite && ( +
    + +
    + )} +
    + ); +}; + +export const LeaderboardHub = ({ + boards, + companies, +}: LeaderboardHubProps): ReactElement => { + const getBoard = useCallback( + (type: LeaderboardType) => boards[type] ?? noItems, + [boards], + ); + const hasBoards = LEADERBOARD_BOARDS.some( + (board) => getBoard(board.type).length > 0, + ); + + return ( + <> + + + {hasBoards && } +
    + +
    + {LEADERBOARD_BOARDS.filter((board) => boards[board.type]).map( + (board) => ( + + ), + )} + +
    +
    + + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardInviteCard.spec.tsx b/packages/shared/src/features/leaderboard/LeaderboardInviteCard.spec.tsx new file mode 100644 index 0000000000..2f753d283d --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardInviteCard.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import nock from 'nock'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { LeaderboardInviteCard } from './LeaderboardInviteCard'; + +beforeEach(() => { + nock.cleanAll(); +}); + +describe('LeaderboardInviteCard', () => { + it('should render the loading state before the ladder arrives', () => { + render( + + + , + ); + + expect(screen.getAllByText('Copy invite link')).toHaveLength(2); + }); +}); diff --git a/packages/shared/src/features/leaderboard/LeaderboardInviteCard.tsx b/packages/shared/src/features/leaderboard/LeaderboardInviteCard.tsx new file mode 100644 index 0000000000..cc45f8aaea --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardInviteCard.tsx @@ -0,0 +1,141 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button } from '../../components/buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; +import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { CopyIcon } from '../../components/icons/Copy'; +import { PlusTitle } from '../../components/plus/PlusTitle'; +import { TypographyType } from '../../components/typography/Typography'; +import { pluralizeFriend } from '../../components/referral/ReferralLadderRewards'; +import { useLogContext } from '../../contexts/LogContext'; +import { useReferralCampaign } from '../../hooks/referral/useReferralCampaign'; +import { useReferralLadder } from '../../hooks/referral/useReferralLadder'; +import { useCopyLink } from '../../hooks/useCopy'; +import { link } from '../../lib/links'; +import { LogEvent, TargetId } from '../../lib/log'; +import { + formatReferralLadderMonths, + getReferralInviteMessage, + getReferralLadderReward, + REFERRAL_INVITE_COPIED_TOAST, + ReferralCampaignKey, +} from '../../lib/referral'; +import { LeaderboardActionCard } from './LeaderboardActionCard'; + +/** The Plus referral ladder, as the invite settings page reads it. */ +export const LeaderboardInviteCard = (): ReactElement | null => { + const { + isEligible, + isLoading, + referredCount, + steps, + nextStep, + remainingInvites, + } = useReferralLadder(); + const { url, isReady } = useReferralCampaign({ + campaignKey: ReferralCampaignKey.Generic, + }); + const [, copyLink] = useCopyLink(() => url || link.referral.defaultUrl); + const { logEvent } = useLogContext(); + + const lastStep = steps[steps.length - 1]; + + if (!isLoading && (!isEligible || !lastStep)) { + return null; + } + + const onCopy = () => { + copyLink({ + format: getReferralInviteMessage, + message: REFERRAL_INVITE_COPIED_TOAST, + }); + logEvent({ + event_name: LogEvent.CopyReferralLink, + target_id: TargetId.LeaderboardInviteCard, + }); + }; + const copyButton = (size: ButtonSize) => ( + + ); + const headline = nextStep + ? `Invite ${remainingInvites} ${pluralizeFriend( + remainingInvites, + )}, get ${formatReferralLadderMonths(nextStep.months)}` + : lastStep && `You unlocked ${getReferralLadderReward(lastStep.months)}`; + + return ( + + + {isLoading ? ( + + ) : ( + headline + )} +
    + } + detail={ + isLoading ? ( + + ) : ( + `${Math.min(referredCount, lastStep.invites)} of ${ + lastStep.invites + } friends joined` + ) + } + aside={ + // Phones: the same small size as Snapshot. + + {copyButton(ButtonSize.XSmall)} + + } + action={copyButton(ButtonSize.Small)} + actionClassName="hidden tablet:flex" + > +
      + {(isLoading ? Array.from({ length: 3 }, () => undefined) : steps).map( + (step, index) => { + const unlocked = !!step?.unlockedAt; + + return ( +
    1. + + {step ? ( + + {formatReferralLadderMonths(step.months)} + {unlocked && , unlocked} + + ) : ( + + )} +
    2. + ); + }, + )} +
    + + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardRankCard.spec.tsx b/packages/shared/src/features/leaderboard/LeaderboardRankCard.spec.tsx new file mode 100644 index 0000000000..0341208fd7 --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardRankCard.spec.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import nock from 'nock'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import type { LoggedUser } from '../../lib/user'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../__tests__/helpers/graphql'; +import type { LeaderboardPosition } from '../../graphql/leaderboard'; +import { + LEADERBOARD_POSITION_QUERY, + LeaderboardType, +} from '../../graphql/leaderboard'; +import { LeaderboardRankCard } from './LeaderboardRankCard'; + +jest.mock('../snapshot/ProfileSnapshotButton', () => ({ + ProfileSnapshotButton: () => , +})); + +const CAP = 100000; + +const mockPositions = ( + positions: Partial>, +): void => { + [ + LeaderboardType.HighestReputation, + LeaderboardType.LongestStreak, + LeaderboardType.MostReadingDays, + ].forEach((type) => + mockGraphQL({ + request: { query: LEADERBOARD_POSITION_QUERY, variables: { type } }, + result: { + data: { + leaderboardPosition: positions[type] ?? { + rank: null, + score: 0, + cappedAt: CAP, + }, + }, + }, + }), + ); +}; + +const createClient = () => + new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const renderCard = (user: LoggedUser | undefined = loggedUser) => + render( + + []} /> + , + ); + +beforeEach(() => { + nock.cleanAll(); +}); + +describe('LeaderboardRankCard', () => { + it('should open on the board you rank best on', async () => { + mockPositions({ + [LeaderboardType.HighestReputation]: { + rank: 842, + score: 3120, + cappedAt: CAP, + }, + [LeaderboardType.LongestStreak]: { rank: 317, score: 46, cappedAt: CAP }, + [LeaderboardType.MostReadingDays]: { + rank: 529, + score: 121, + cappedAt: CAP, + }, + }); + + renderCard(); + + await screen.findByText('You’re #317 on Longest streak'); + expect(screen.getByText('days in a row')).toBeInTheDocument(); + // One for phones, one from tablet up; the stylesheet hides the other. + expect(screen.getAllByRole('button', { name: 'Snapshot' })).toHaveLength(2); + }); + + it('should say you are past the counted range when there is no rank', async () => { + mockPositions({ + [LeaderboardType.HighestReputation]: { + rank: null, + score: 12, + cappedAt: CAP, + }, + }); + + renderCard(); + + await screen.findByText('You’re outside the top 100K'); + }); + + it('should say how to earn a rank and hide the snapshot with no score', async () => { + mockPositions({}); + + renderCard(); + + await screen.findByText('No rank yet on Highest reputation'); + expect( + screen.getByText( + 'Earn reputation when people upvote your posts and comments.', + ), + ).toBeInTheDocument(); + await waitFor(() => expect(nock.isDone()).toBe(true)); + expect(screen.queryAllByRole('button', { name: 'Snapshot' })).toHaveLength( + 0, + ); + }); + + it('should say the rank is unavailable when the answer fails', async () => { + [ + LeaderboardType.HighestReputation, + LeaderboardType.LongestStreak, + LeaderboardType.MostReadingDays, + ].forEach((type) => + mockGraphQL({ + request: { query: LEADERBOARD_POSITION_QUERY, variables: { type } }, + result: { errors: [{ message: 'boom' } as never] }, + }), + ); + + renderCard(); + + await screen.findByText( + 'Your rank on Highest reputation isn’t available right now', + ); + expect(screen.getByText('Try again in a moment.')).toBeInTheDocument(); + expect(screen.queryAllByRole('button', { name: 'Snapshot' })).toHaveLength( + 0, + ); + }); + + it('should render nothing signed out', () => { + const { container } = render( + + []} /> + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/shared/src/features/leaderboard/LeaderboardRankCard.tsx b/packages/shared/src/features/leaderboard/LeaderboardRankCard.tsx new file mode 100644 index 0000000000..8dd61f111e --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardRankCard.tsx @@ -0,0 +1,250 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { Dropdown } from '../../components/fields/Dropdown'; +import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../components/ProfilePicture'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { useIsPhone } from '../../hooks/useViewSize'; +import type { + LeaderboardPosition, + UserLeaderboard, +} from '../../graphql/leaderboard'; +import { + LeaderboardType, + leaderboardPositionQueryOptions, + leaderboardTypeToTitle, +} from '../../graphql/leaderboard'; +import { Origin } from '../../lib/log'; +import { ProfileSnapshotButton } from '../snapshot/ProfileSnapshotButton'; +import { + formatLeaderboardCapValue, + formatLeaderboardScore, + formatLeaderboardScoreWithUnit, + formatLeaderboardUnit, + getLeaderboardBoard, + LEADERBOARD_POSITION_BOARDS, +} from './boards'; +import { LeaderboardActionCard } from './LeaderboardActionCard'; +import { LeaderboardReputation, LeaderboardStreak } from './LeaderboardScore'; +import { youHighlightClassName } from './LeaderboardRows'; +import { LeaderboardSnapshotCard } from './LeaderboardSnapshotCard'; +import { leaderboardRankPickerEvent } from './leaderboardLog'; +import { + getLeaderboardRankLabel, + getLeaderboardRankState, +} from './useLeaderboardPosition'; + +const SNAPSHOT_ROWS = 3; + +/** + * The board you rank best on, so the card opens on your best result. Past + * the cap or with no score there is no rank to compare: the first board. + */ +const getBestBoard = ( + positions: (LeaderboardPosition | null | undefined)[], +): LeaderboardType => { + let best = 0; + let bestRank = Infinity; + positions.forEach((position, index) => { + if (position?.rank && position.score > 0 && position.rank < bestRank) { + best = index; + bestRank = position.rank; + } + }); + + return LEADERBOARD_POSITION_BOARDS[best].type; +}; + +const pickerOptions = LEADERBOARD_POSITION_BOARDS.map( + (board) => leaderboardTypeToTitle[board.type], +); +// Phones: the button says the board's short name; the sheet keeps the titles. +const shortPickerOptions = LEADERBOARD_POSITION_BOARDS.map( + (board) => board.short, +); +const renderPickerTitle = (_: string, index: number): string => + pickerOptions[index]; + +interface LeaderboardRankCardProps { + /** Each board's top rows, for the snapshot's top 3. */ + getBoard: (type: LeaderboardType) => UserLeaderboard[]; +} + +export const LeaderboardRankCard = ({ + getBoard, +}: LeaderboardRankCardProps): ReactElement | null => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const isPhone = useIsPhone(); + const [picked, setPicked] = useState(); + const positions = useQueries({ + queries: LEADERBOARD_POSITION_BOARDS.map((board) => ({ + ...leaderboardPositionQueryOptions(user, board.type), + enabled: !!user, + })), + }); + + if (!user) { + return null; + } + + if (positions.some((query) => query.isPending)) { + return ( + } + detail={} + > + + + ); + } + + const board = picked ?? getBestBoard(positions.map((query) => query.data)); + const boardIndex = LEADERBOARD_POSITION_BOARDS.findIndex( + (item) => item.type === board, + ); + const { data: position, isError } = positions[boardIndex]; + const state = getLeaderboardRankState(position); + const title = leaderboardTypeToTitle[board]; + const score = position?.score ?? 0; + + const headline = ((): string => { + // A failed answer is not "no rank": it says nothing about you. + if (isError) { + return `Your rank on ${title} isn’t available right now`; + } + if (state === 'empty' || !position) { + return `No rank yet on ${title}`; + } + if (state === 'capped') { + return `You’re outside the top ${formatLeaderboardCapValue( + position.cappedAt, + )}`; + } + + return `You’re ${getLeaderboardRankLabel(position)} on ${title}`; + })(); + + const detail = ((): ReactNode => { + if (isError) { + return 'Try again in a moment.'; + } + if (state === 'empty') { + return getLeaderboardBoard(board).howTo; + } + if (board === LeaderboardType.HighestReputation) { + return ( +
    + reputation in total +
    + ); + } + if (board === LeaderboardType.LongestStreak) { + return ( + + days in a row + + ); + } + + return `${formatLeaderboardScoreWithUnit(board, score)} in total`; + })(); + + const heading = ( +
    + +
    + {headline} +
    + {detail} +
    +
    +
    + ); + + // The profile snapshot flow: the card staged off-screen at its full size, + // captured, copied, then the snapshot share panel. + const snapshot = (size: ButtonSize): ReactNode => + isError || state === 'empty' || !position ? undefined : ( + ( + ({ + rank: index + 1, + name: entry.user.name, + value: formatLeaderboardScoreWithUnit(board, entry.score), + }))} + /> + )} + /> + ); + + return ( + + { + const { type } = LEADERBOARD_POSITION_BOARDS[index]; + setPicked(type); + logEvent(leaderboardRankPickerEvent(type)); + }} + shouldIndicateSelected + /> + + {snapshot(ButtonSize.XSmall)} + + + } + action={snapshot(ButtonSize.Small)} + actionClassName="hidden tablet:flex" + /> + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardRows.tsx b/packages/shared/src/features/leaderboard/LeaderboardRows.tsx new file mode 100644 index 0000000000..462236e5bc --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardRows.tsx @@ -0,0 +1,175 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ArrowIcon } from '../../components/icons/Arrow'; +import { IconSize } from '../../components/Icon'; +import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { TopRankBadge } from '../../components/cards/Leaderboard/TopRankBadge'; +import Link from '../../components/utilities/Link'; + +/** Your own row and card: a soft tint, no border. */ +export const youHighlightClassName = 'bg-theme-overlay-float-cabbage'; + +export const LeaderboardHeading = ({ + title, + action, +}: { + title: ReactNode; + action?: ReactNode; +}): ReactElement => ( +
    +

    {title}

    + {action} +
    +); + +const textLinkClassName = + 'flex shrink-0 items-center gap-1 font-bold text-text-tertiary typo-footnote'; + +/** A link-styled cue; with no `href` it is only a pressable card's label. */ +export const LeaderboardTextLink = ({ + children, + href, + onClick, + ariaLabel, + ariaExpanded, +}: { + children: ReactNode; + href?: string; + onClick?: (event: React.MouseEvent) => void; + ariaLabel?: string; + /** Set when the link opens a list in place instead of its page. */ + ariaExpanded?: boolean; +}): ReactElement => { + const content = ( + <> + {children} + + + ); + + if (!href) { + return {content}; + } + + return ( + + + {content} + + + ); +}; + +/** A medal for the top 3, the number for everyone else. Never blank. */ +export const LeaderboardRank = ({ + rank, + className, +}: { + rank: number; + className?: string; +}): ReactElement => ( + + {rank <= 3 ? ( + <> + + {rank} + + ) : ( + + {rank.toLocaleString('en-US')} + + )} + +); + +export const LeaderboardRowPlaceholder = (): ReactElement => ( +
  • + + + +
  • +); + +export const LeaderboardEmptyBoard = (): ReactElement => ( +

    + Nothing here right now. Check back soon. +

    +); + +/** + * A row on a board tile and in its full list: rank, who, and the score. With + * an `href` the row is the person's profile link. + */ +export const LeaderboardMiniRow = ({ + rank, + name, + image, + value, + highlight = false, + href, + onClick, +}: { + rank: number | string; + name: string; + image: ReactNode; + value: ReactNode; + highlight?: boolean; + href?: string; + onClick?: () => void; +}): ReactElement => { + const className = classNames( + 'flex h-9 items-center gap-2 rounded-10 px-1.5', + highlight ? youHighlightClassName : 'hover:bg-surface-hover', + ); + const content = ( + <> + + {typeof rank === 'number' && rank <= 3 ? ( + + ) : ( + + {typeof rank === 'number' ? rank.toLocaleString('en-US') : rank} + + )} + + {image} + + {name} + +
    + {value} +
    + + ); + + return ( +
  • + {href ? ( + + + {content} + + + ) : ( +
    {content}
    + )} +
  • + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardScore.tsx b/packages/shared/src/features/leaderboard/LeaderboardScore.tsx new file mode 100644 index 0000000000..8875043c60 --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardScore.tsx @@ -0,0 +1,135 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ReadingStreakIcon } from '../../components/icons/ReadingStreak'; +import { IconSize } from '../../components/Icon'; +import { ReputationUserBadge } from '../../components/ReputationUserBadge'; +import { getQuestLevelProgress } from '../../components/quest/QuestLevelProgressCircle'; +import type { QuestLevel } from '../../graphql/quests'; +import { LeaderboardType } from '../../graphql/leaderboard'; +import { formatLeaderboardScore } from './boards'; + +interface ScoreProps { + className?: string; + /** The rank card's size: a step up in text and icon. */ + large?: boolean; +} + +/** Reputation reads as the profile's badge: the bolt and the number. */ +export const LeaderboardReputation = ({ + value, + className, + large = false, +}: ScoreProps & { value: number }): ReactElement => ( + +); + +/** Streak days read as the header's streak counter: the flame and the days. */ +export const LeaderboardStreak = ({ + days, + className, + large = false, +}: ScoreProps & { days: number }): ReactElement => ( + + {/* The flame fills its box while the reputation bolt sits inside one, + so the flame is drawn smaller in the bolt's slot to match it. */} + + + + {formatLeaderboardScore(days)} + +); + +const RING_SIZE = 16; +const RING_STROKE = 3; +const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +/** The level ring in its colors, small enough for a row. */ +const LevelRing = ({ progress }: { progress: number }): ReactElement => ( + + + + +); + +/** Highest level reads as the level, with a small ring for its progress. */ +export const LeaderboardLevel = ({ + level, + label = false, + className, +}: { + level: QuestLevel; + /** Says "Level 120" where the board's title isn't beside it. */ + label?: boolean; + className?: string; +}): ReactElement => ( + + + {label ? `Level ${level.level}` : level.level} + +); + +/** A board's score the way its own surface shows it, or the plain number. */ +export const LeaderboardScoreValue = ({ + type, + score, + level, + className, +}: { + type: LeaderboardType; + score: number; + level?: QuestLevel | null; + className?: string; +}): ReactNode => { + if (type === LeaderboardType.HighestReputation) { + return ; + } + if (type === LeaderboardType.HighestLevel && level) { + return ; + } + if (type === LeaderboardType.LongestStreak) { + return ; + } + return formatLeaderboardScore(score); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardSnapshotCard.tsx b/packages/shared/src/features/leaderboard/LeaderboardSnapshotCard.tsx new file mode 100644 index 0000000000..3cfe8a1b2e --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardSnapshotCard.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotEyebrow } from '../snapshot/SnapshotEyebrow'; +import { SnapshotFrame } from '../snapshot/SnapshotFrame'; +import type { SnapshotIdentityProps } from '../snapshot/SnapshotIdentity'; +import { SnapshotIdentity } from '../snapshot/SnapshotIdentity'; +import { SnapshotTile } from '../snapshot/SnapshotStats'; + +const MUTED = colors.salt['90']; + +export interface LeaderboardSnapshotRow { + rank: number; + name: string; + value: string; +} + +export interface LeaderboardSnapshotCardProps { + user: SnapshotIdentityProps; + /** "Highest reputation", "Most reading days"… */ + title: string; + /** A rank past the counted range reads "100K+". */ + rank: string; + stat: { value: string; label: string }; + /** What the rows under the tiles are. */ + listTitle: string; + rows: LeaderboardSnapshotRow[]; +} + +/** + * The leaderboard's share image, composed the way the Reading overview card + * is: the mark with a surface label, who it is, two headline tiles, then the + * board's top rows. + */ +function LeaderboardSnapshotCardComponent( + { user, title, rank, stat, listTitle, rows }: LeaderboardSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + > +
    + +
    + + +
    +
    + {listTitle} +
      + {rows.map((row) => ( +
    1. + + {row.rank} + + + {row.name} + + {row.value} +
    2. + ))} +
    +
    +
    +
    + ); +} + +export const LeaderboardSnapshotCard = forwardRef( + LeaderboardSnapshotCardComponent, +); diff --git a/packages/shared/src/features/leaderboard/LeaderboardStandouts.tsx b/packages/shared/src/features/leaderboard/LeaderboardStandouts.tsx new file mode 100644 index 0000000000..a8f849e2e6 --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardStandouts.tsx @@ -0,0 +1,118 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../components/ProfilePicture'; +import { ProfileTooltip } from '../../components/profile/ProfileTooltip'; +import Link from '../../components/utilities/Link'; +import { useLogContext } from '../../contexts/LogContext'; +import type { UserLeaderboard } from '../../graphql/leaderboard'; +import { LeaderboardType } from '../../graphql/leaderboard'; +import { + formatLeaderboardScoreWithUnit, + LEADERBOARD_STANDOUT_BOARDS, +} from './boards'; +import { LeaderboardHeading } from './LeaderboardRows'; +import { leaderboardUserClickEvent } from './leaderboardLog'; +import { + LeaderboardLevel, + LeaderboardReputation, + LeaderboardStreak, +} from './LeaderboardScore'; + +const standoutStat = ( + type: LeaderboardType, + entry: UserLeaderboard, +): ReactNode => { + if (type === LeaderboardType.HighestLevel && entry.level) { + return ; + } + if (type === LeaderboardType.HighestReputation) { + return ( + + ); + } + if (type === LeaderboardType.LongestStreak) { + return ( + + ); + } + + return formatLeaderboardScoreWithUnit(type, entry.score); +}; + +interface LeaderboardStandoutsProps { + getBoard: (type: LeaderboardType) => UserLeaderboard[]; +} + +/** The #1 of every people board, in a row you scroll sideways on phones. */ +export const LeaderboardStandouts = ({ + getBoard, +}: LeaderboardStandoutsProps): ReactElement | null => { + const { logEvent } = useLogContext(); + const standouts = LEADERBOARD_STANDOUT_BOARDS.flatMap((board) => { + const [top] = getBoard(board.type); + + return top ? [{ board, top }] : []; + }); + + if (standouts.length === 0) { + return null; + } + + return ( +
    + + +
    + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardTopReputation.tsx b/packages/shared/src/features/leaderboard/LeaderboardTopReputation.tsx new file mode 100644 index 0000000000..c54effe6d8 --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardTopReputation.tsx @@ -0,0 +1,112 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../components/ProfilePicture'; +import { ProfileTooltip } from '../../components/profile/ProfileTooltip'; +import Link from '../../components/utilities/Link'; +import { useLogContext } from '../../contexts/LogContext'; +import type { UserLeaderboard } from '../../graphql/leaderboard'; +import { + LeaderboardType, + leaderboardTypeToTitle, +} from '../../graphql/leaderboard'; +import { LeaderboardReputation } from './LeaderboardScore'; +import { + leaderboardBoardClickEvent, + leaderboardUserClickEvent, +} from './leaderboardLog'; +import { + LeaderboardEmptyBoard, + LeaderboardHeading, + LeaderboardRank, + LeaderboardTextLink, +} from './LeaderboardRows'; + +const PersonRow = ({ + entry, + rank, + onClick, +}: { + entry: UserLeaderboard; + rank: number; + onClick: () => void; +}): ReactElement => ( +
  • + + + + + + + + + + {entry.user.name} + + + + +
  • +); + +/** The top of Highest reputation, in columns, with its full ranking a press away. */ +export const LeaderboardTopReputation = ({ + items, +}: { + items: UserLeaderboard[]; +}): ReactElement => { + const { logEvent } = useLogContext(); + const board = LeaderboardType.HighestReputation; + + return ( +
    + logEvent(leaderboardBoardClickEvent(board, 'page'))} + > + Full ranking + + } + /> + {items.length === 0 ? ( + + ) : ( +
      + {items.map((entry, index) => ( + + logEvent( + leaderboardUserClickEvent({ + userId: entry.user.id, + placement: 'top reputation', + board, + rank: index + 1, + }), + ) + } + /> + ))} +
    + )} +
    + ); +}; diff --git a/packages/shared/src/features/leaderboard/LeaderboardUserBoardTile.tsx b/packages/shared/src/features/leaderboard/LeaderboardUserBoardTile.tsx new file mode 100644 index 0000000000..c4230a384d --- /dev/null +++ b/packages/shared/src/features/leaderboard/LeaderboardUserBoardTile.tsx @@ -0,0 +1,106 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../components/ProfilePicture'; +import { ProfileTooltip } from '../../components/profile/ProfileTooltip'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import type { + LeaderboardType, + UserLeaderboard, +} from '../../graphql/leaderboard'; +import { leaderboardTypeToTitle } from '../../graphql/leaderboard'; +import { LeaderboardBoardTile } from './LeaderboardBoardTile'; +import { leaderboardUserClickEvent } from './leaderboardLog'; +import { LeaderboardMiniRow } from './LeaderboardRows'; +import { LeaderboardScoreValue } from './LeaderboardScore'; +import { formatLeaderboardCap } from './boards'; +import { useLeaderboardPosition } from './useLeaderboardPosition'; + +interface LeaderboardUserBoardTileProps { + type: LeaderboardType; + items: UserLeaderboard[]; +} + +export const LeaderboardUserBoardTile = ({ + type, + items, +}: LeaderboardUserBoardTileProps): ReactElement => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { position, state } = useLeaderboardPosition(type); + const you = + user && position && state !== 'empty' ? ( + + logEvent( + leaderboardUserClickEvent({ + userId: user.id, + placement: 'board', + board: type, + rank: position.rank ?? undefined, + }), + ) + } + image={ + + } + value={} + highlight + /> + ) : undefined; + + return ( + ( + + logEvent( + leaderboardUserClickEvent({ + userId: item.user.id, + placement: 'board', + board: type, + rank, + }), + ) + } + image={ + + + + + + } + value={ + + } + /> + )} + /> + ); +}; diff --git a/packages/shared/src/features/leaderboard/boards.ts b/packages/shared/src/features/leaderboard/boards.ts new file mode 100644 index 0000000000..b1089a0216 --- /dev/null +++ b/packages/shared/src/features/leaderboard/boards.ts @@ -0,0 +1,127 @@ +import { + isLeaderboardPositionSupported, + LeaderboardType, +} from '../../graphql/leaderboard'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { pluralize } from '../../lib/strings'; + +export interface LeaderboardBoard { + type: LeaderboardType; + /** What the rank card's picker says on phones, where the title is too long. */ + short: string; + /** The unit after a score, stored plural: "1,204 days". */ + unit: string; + /** The Standouts card's label over the board's #1. */ + eyebrow: string; + /** One line on what moves this number, for a reader with no rank yet. */ + howTo: string; +} + +/** /users' people boards, in its order; Most verified employees closes the grid. */ +export const LEADERBOARD_BOARDS: LeaderboardBoard[] = [ + { + type: LeaderboardType.HighestLevel, + short: 'Level', + unit: 'XP', + eyebrow: 'Highest level', + howTo: 'Complete daily and weekly quests to earn XP and level up.', + }, + { + type: LeaderboardType.HighestReputation, + short: 'Reputation', + unit: 'rep', + eyebrow: 'Highest reputation', + howTo: 'Earn reputation when people upvote your posts and comments.', + }, + { + type: LeaderboardType.LongestStreak, + short: 'Streak', + unit: 'days', + eyebrow: 'Longest streak', + howTo: 'Read at least one post a day to keep your streak going.', + }, + { + type: LeaderboardType.HighestPostViews, + short: 'Post views', + unit: 'views', + eyebrow: 'Most post views', + howTo: 'Post in squads. Views count across everything you share.', + }, + { + type: LeaderboardType.MostUpvoted, + short: 'Upvotes', + unit: 'upvotes', + eyebrow: 'Most upvoted', + howTo: 'Share posts and write comments other developers upvote.', + }, + { + type: LeaderboardType.MostReferrals, + short: 'Referrals', + unit: 'referrals', + eyebrow: 'Top referrer', + howTo: 'Invite developer friends. Each signup counts once.', + }, + { + type: LeaderboardType.MostReadingDays, + short: 'Reading days', + unit: 'days', + eyebrow: 'Most reading days', + howTo: 'Every day you read at least one post adds a reading day.', + }, +]; + +/** Standouts' order: Highest level closes the row, as its #1 reads as a level, not a score. */ +export const LEADERBOARD_STANDOUT_BOARDS: LeaderboardBoard[] = [ + ...LEADERBOARD_BOARDS.filter( + (board) => board.type !== LeaderboardType.HighestLevel, + ), + ...LEADERBOARD_BOARDS.filter( + (board) => board.type === LeaderboardType.HighestLevel, + ), +]; + +/** The boards leaderboardPosition can rank you on, in the page's order. */ +export const LEADERBOARD_POSITION_BOARDS = LEADERBOARD_BOARDS.filter((board) => + isLeaderboardPositionSupported(board.type), +); + +/** Rows on a board tile; your row takes the last one where you are ranked. */ +export const LEADERBOARD_TILE_ROWS = 4; + +export const getLeaderboardBoard = (type: LeaderboardType): LeaderboardBoard => + LEADERBOARD_BOARDS.find((board) => board.type === type) as LeaderboardBoard; + +/** "1,602", "29.6K", "2.1M": one decimal from 10K up, none when it is zero. */ +export const formatLeaderboardScore = (score: number): string => { + if (score >= 1000000) { + return `${(score / 1000000).toFixed(1).replace(/\.0$/, '')}M`; + } + if (score >= 10000) { + return `${(score / 1000).toFixed(1).replace(/\.0$/, '')}K`; + } + + return score.toLocaleString('en-US'); +}; + +/** "1 day", "2 days", "480 XP": units are stored plural. */ +export const formatLeaderboardUnit = (score: number, unit: string): string => + unit.endsWith('s') ? pluralize(unit.slice(0, -1), score) : unit; + +export const formatLeaderboardScoreWithUnit = ( + type: LeaderboardType, + score: number, +): string => + `${formatLeaderboardScore(score)} ${formatLeaderboardUnit( + score, + getLeaderboardBoard(type).unit, + )}`; + +export const formatLeaderboardRank = (rank: number): string => + `#${rank.toLocaleString('en-US')}`; + +/** Past the counted range leaderboardPosition has no rank, only the cap: "100K". */ +export const formatLeaderboardCapValue = (cappedAt: number): string => + largeNumberFormat(cappedAt) ?? cappedAt.toString(); + +export const formatLeaderboardCap = (cappedAt: number): string => + `${formatLeaderboardCapValue(cappedAt)}+`; diff --git a/packages/shared/src/features/leaderboard/leaderboardLog.ts b/packages/shared/src/features/leaderboard/leaderboardLog.ts new file mode 100644 index 0000000000..e187d5b8bd --- /dev/null +++ b/packages/shared/src/features/leaderboard/leaderboardLog.ts @@ -0,0 +1,45 @@ +import type { LogEvent as LogEventData } from '../../hooks/log/useLogQueue'; +import type { LeaderboardType } from '../../graphql/leaderboard'; +import { LogEvent, TargetType } from '../../lib/log'; + +/** Where a board's list opened: in place, as a phone sheet, or its own page. */ +export type LeaderboardBoardSurface = 'popover' | 'sheet' | 'page'; + +/** Where on the page a person was pressed. */ +export type LeaderboardUserPlacement = 'top reputation' | 'standouts' | 'board'; + +export const leaderboardBoardClickEvent = ( + type: LeaderboardType, + surface: LeaderboardBoardSurface, +): LogEventData => ({ + event_name: LogEvent.Click, + target_type: TargetType.LeaderboardBoard, + target_id: type, + extra: JSON.stringify({ surface }), +}); + +export const leaderboardRankPickerEvent = ( + type: LeaderboardType, +): LogEventData => ({ + event_name: LogEvent.Click, + target_type: TargetType.LeaderboardRankPicker, + target_id: type, +}); + +export const leaderboardUserClickEvent = ({ + userId, + placement, + board, + rank, +}: { + userId: string; + placement: LeaderboardUserPlacement; + board: LeaderboardType; + /** Left out for your own pinned row, which has no counted rank past the cap. */ + rank?: number; +}): LogEventData => ({ + event_name: LogEvent.Click, + target_type: TargetType.LeaderboardUser, + target_id: userId, + extra: JSON.stringify({ placement, board, rank }), +}); diff --git a/packages/shared/src/features/leaderboard/useLeaderboardPosition.ts b/packages/shared/src/features/leaderboard/useLeaderboardPosition.ts new file mode 100644 index 0000000000..dd68b1f19c --- /dev/null +++ b/packages/shared/src/features/leaderboard/useLeaderboardPosition.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthContext } from '../../contexts/AuthContext'; +import type { + LeaderboardPosition, + LeaderboardType, +} from '../../graphql/leaderboard'; +import { + isLeaderboardPositionSupported, + leaderboardPositionQueryOptions, +} from '../../graphql/leaderboard'; +import { formatLeaderboardCap, formatLeaderboardRank } from './boards'; + +/** How leaderboardPosition answers for you: a rank, past the cap, or nothing yet. */ +export type LeaderboardRankState = 'ranked' | 'capped' | 'empty'; + +export const getLeaderboardRankState = ( + position?: LeaderboardPosition | null, +): LeaderboardRankState => { + if (!position || position.score <= 0) { + return 'empty'; + } + + return position.rank === null ? 'capped' : 'ranked'; +}; + +/** "#842", or "100K+" past the counted range. */ +export const getLeaderboardRankLabel = ( + position: LeaderboardPosition, +): string => + position.rank === null + ? formatLeaderboardCap(position.cappedAt) + : formatLeaderboardRank(position.rank); + +interface UseLeaderboardPosition { + position?: LeaderboardPosition | null; + state: LeaderboardRankState; + isLoading: boolean; + isError: boolean; +} + +/** Your rank on one board, where production can answer for it. */ +export const useLeaderboardPosition = ( + type: LeaderboardType, +): UseLeaderboardPosition => { + const { user, isAuthReady } = useAuthContext(); + const enabled = isAuthReady && !!user && isLeaderboardPositionSupported(type); + const { data, isPending, isError } = useQuery({ + ...leaderboardPositionQueryOptions(user, type), + enabled, + }); + + return { + position: data, + state: getLeaderboardRankState(data), + isLoading: enabled && isPending, + isError, + }; +}; diff --git a/packages/shared/src/graphql/leaderboard.ts b/packages/shared/src/graphql/leaderboard.ts index 4c24dc1616..c4d9362bce 100644 --- a/packages/shared/src/graphql/leaderboard.ts +++ b/packages/shared/src/graphql/leaderboard.ts @@ -1,5 +1,10 @@ import { gql } from 'graphql-request'; import { LEADERBOARD_FRAGMENT } from './fragments'; +import { gqlClient } from './common'; +import type { QuestLevel } from './quests'; +import type { Company } from '../lib/userCompany'; +import type { LoggedUser, UserShortProfile } from '../lib/user'; +import { generateQueryKey, RequestKey, StaleTime } from '../lib/query'; export const LEADERBOARD_QUERY = gql` query Leaderboard($limit: Int = 10) { @@ -46,6 +51,22 @@ export enum LeaderboardType { export const MOST_QUESTS_COMPLETED_LIMIT = 10; +/** Rows a board on /users shows: Highest reputation fills three columns of four. */ +export const LEADERBOARD_LIST_LIMIT = 12; +/** Rows a board's full list on /users opens with. */ +export const LEADERBOARD_TOP_LIMIT = 50; + +export interface UserLeaderboard { + score: number; + user: UserShortProfile; + level?: QuestLevel | null; +} + +export interface CompanyLeaderboard { + score: number; + company: Pick; +} + export type QuestCompletionLeader = { questId: string; questName: string; @@ -100,6 +121,21 @@ export const LEADERBOARD_POSITION_QUERY = gql` } `; +export const leaderboardPositionQueryOptions = ( + user: Pick | undefined, + type: LeaderboardType, +) => ({ + queryKey: generateQueryKey(RequestKey.LeaderboardPosition, user, type), + queryFn: async (): Promise => { + const res = await gqlClient.request<{ + leaderboardPosition: LeaderboardPosition | null; + }>(LEADERBOARD_POSITION_QUERY, { type }); + + return res.leaderboardPosition; + }, + staleTime: StaleTime.Default, +}); + export const HIGHEST_REPUTATION_QUERY = gql` query HighestReputation($limit: Int = 100) { highestReputation(limit: $limit) { @@ -221,3 +257,21 @@ export const leaderboardQueries: Record = { [LeaderboardType.HighestLevel]: HIGHEST_LEVEL_QUERY, [LeaderboardType.MostVerifiedUsers]: MOST_VERIFIED_USERS_QUERY, }; + +export const leaderboardBoardQueryOptions = < + T extends UserLeaderboard | CompanyLeaderboard, +>( + type: LeaderboardType, + limit: number, +) => ({ + queryKey: generateQueryKey(RequestKey.Leaderboard, undefined, type, limit), + queryFn: async (): Promise => { + const res = await gqlClient.request>( + leaderboardQueries[type], + { limit }, + ); + + return res[type] ?? []; + }, + staleTime: StaleTime.Default, +}); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index f8fa79b2d0..09ecc591ba 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -759,6 +759,9 @@ export enum TargetType { ProfilePage = 'profile page', GenericReferralPopup = 'generic referral popup', ReferralInviterCard = 'referral inviter card', + LeaderboardBoard = 'leaderboard board', + LeaderboardRankPicker = 'leaderboard rank picker', + LeaderboardUser = 'leaderboard user', Shortcuts = 'shortcuts', FeedWidget = 'feed widget', ChatRoom = 'chat room', @@ -837,6 +840,7 @@ export enum TargetId { GenericReferralPopup = 'generic referral popup', ReferralLadderPopup = 'referral ladder popup', ReferralLadderPromo = 'referral ladder promo', + LeaderboardInviteCard = 'leaderboard invite card', ProfilePage = 'profile page', InviteFriendsPage = 'invite friends page', Squad = 'squad', diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index b2e48f94b3..f15a6c00c5 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -300,6 +300,7 @@ export enum RequestKey { MarketingCtas = 'marketing_ctas', HackathonParticipation = 'hackathon_participation', BrowserExtensionInstalled = 'browser_extension_installed', + Leaderboard = 'leaderboard', LeaderboardPosition = 'leaderboard_position', ShellState = 'shell_state', AchievementTracker = 'achievement_tracker', diff --git a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts index 8d6d58a0ea..5cebc50399 100644 --- a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts +++ b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts @@ -1,7 +1,8 @@ -import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; +import type { UserLeaderboard } from '@dailydotdev/shared/src/graphql/leaderboard'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { HIGHEST_LEVEL_QUERY, + LEADERBOARD_LIST_LIMIT, LEADERBOARD_QUERY, LeaderboardType, MOST_QUESTS_COMPLETED_LIMIT, @@ -63,12 +64,18 @@ describe('leaderboard static props', () => { const result = await getUsersStaticProps(); + expect(mockRequest).toHaveBeenCalledWith(LEADERBOARD_QUERY, { + limit: LEADERBOARD_LIST_LIMIT, + }); expect(result).toMatchObject({ props: { - highestLevel: [], - isHighestLevelSupported: false, + boards: { highestReputation: [] }, + companies: [], }, }); + expect( + (result as { props: { boards: Record } }).props.boards, + ).not.toHaveProperty(LeaderboardType.HighestLevel); }); it('should include highest-level data when schema is supported', async () => { @@ -82,8 +89,8 @@ describe('leaderboard static props', () => { image: '', permalink: '/users/user-1', createdAt: new Date().toISOString(), - providers: [], - balance: { amount: 0 }, + bio: '', + reputation: 0, }, level: { level: 10, @@ -108,10 +115,12 @@ describe('leaderboard static props', () => { const result = await getUsersStaticProps(); + expect(mockRequest).toHaveBeenCalledWith(HIGHEST_LEVEL_QUERY, { + limit: LEADERBOARD_LIST_LIMIT, + }); expect(result).toMatchObject({ props: { - highestLevel, - isHighestLevelSupported: true, + boards: { highestLevel }, }, }); }); diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx index 3de108fdd0..aaa8818522 100644 --- a/packages/webapp/pages/users.tsx +++ b/packages/webapp/pages/users.tsx @@ -3,8 +3,13 @@ import React from 'react'; import type { GetStaticPropsResult } from 'next'; import type { NextSeoProps } from 'next-seo/lib/types'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import type { + CompanyLeaderboard, + UserLeaderboard, +} from '@dailydotdev/shared/src/graphql/leaderboard'; import { HIGHEST_LEVEL_QUERY, + LEADERBOARD_LIST_LIMIT, LEADERBOARD_QUERY, LeaderboardType, } from '@dailydotdev/shared/src/graphql/leaderboard'; @@ -12,13 +17,9 @@ import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; import { ExploreHubHeader } from '@dailydotdev/shared/src/components/header/ExploreHubHeader'; import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; import { useLaptopLayout } from '@dailydotdev/shared/src/hooks/layout/useLaptopLayout'; -import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; -import { UserTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard'; -import type { CompanyLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard/CompanyTopList'; -import { CompanyTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard/CompanyTopList'; import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; -import { SpotlightField } from '@dailydotdev/shared/src/components/spotlight/SpotlightTrigger'; -import { SpotlightScope } from '@dailydotdev/shared/src/components/spotlight/types'; +import type { LeaderboardBoards } from '@dailydotdev/shared/src/features/leaderboard/LeaderboardHub'; +import { LeaderboardHub } from '@dailydotdev/shared/src/features/leaderboard/LeaderboardHub'; import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; import { getLayout } from '../components/layouts/MainLayout'; import { defaultOpenGraph } from '../next-seo'; @@ -33,17 +34,20 @@ const seo: NextSeoProps = { }; interface PageProps { - highestReputation: UserLeaderboard[]; - longestStreak: UserLeaderboard[]; - highestPostViews: UserLeaderboard[]; - mostUpvoted: UserLeaderboard[]; - mostReferrals: UserLeaderboard[]; - mostReadingDays: UserLeaderboard[]; - highestLevel: UserLeaderboard[]; - isHighestLevelSupported: boolean; - mostVerifiedUsers: CompanyLeaderboard[]; + boards: LeaderboardBoards; + companies: CompanyLeaderboard[]; } +type LeaderboardQueryResult = Record< + Exclude< + LeaderboardType, + | LeaderboardType.HighestLevel + | LeaderboardType.MostQuestsCompleted + | LeaderboardType.MostVerifiedUsers + >, + UserLeaderboard[] +> & { mostVerifiedUsers: CompanyLeaderboard[] }; + const isHighestLevelSchemaMissing = (error: GraphQLError): boolean => { return ( error?.response?.errors?.some( @@ -54,92 +58,14 @@ const isHighestLevelSchemaMissing = (error: GraphQLError): boolean => { ); }; -const LeaderboardPage = ({ - highestReputation, - longestStreak, - highestPostViews, - mostUpvoted, - mostReferrals, - mostReadingDays, - highestLevel, - isHighestLevelSupported, - mostVerifiedUsers, -}: PageProps): ReactElement => { +const LeaderboardPage = ({ boards, companies }: PageProps): ReactElement => { const isLaptopLayout = useLaptopLayout(); return ( <> {isLaptopLayout && } - - -
    - {isHighestLevelSupported && ( - - )} - - - - - - - -
    + + @@ -160,20 +86,17 @@ export async function getStaticProps(): Promise< GetStaticPropsResult > { try { - const res = - await gqlClient.request>( - LEADERBOARD_QUERY, - ); - - let highestLevel: UserLeaderboard[] = []; - let isHighestLevelSupported = false; + const { mostVerifiedUsers, ...res } = + await gqlClient.request(LEADERBOARD_QUERY, { + limit: LEADERBOARD_LIST_LIMIT, + }); + const boards: LeaderboardBoards = res; try { const levelRes = await gqlClient.request<{ highestLevel: UserLeaderboard[]; - }>(HIGHEST_LEVEL_QUERY, { limit: 10 }); - highestLevel = levelRes.highestLevel ?? []; - isHighestLevelSupported = true; + }>(HIGHEST_LEVEL_QUERY, { limit: LEADERBOARD_LIST_LIMIT }); + boards[LeaderboardType.HighestLevel] = levelRes.highestLevel ?? []; } catch (levelError: unknown) { const error = levelError as GraphQLError; @@ -183,17 +106,7 @@ export async function getStaticProps(): Promise< } return { - props: { - highestReputation: res.highestReputation, - longestStreak: res.longestStreak, - highestPostViews: res.highestPostViews, - mostUpvoted: res.mostUpvoted, - mostReferrals: res.mostReferrals, - mostReadingDays: res.mostReadingDays, - highestLevel, - isHighestLevelSupported, - mostVerifiedUsers: res.mostVerifiedUsers, - }, + props: { boards, companies: mostVerifiedUsers }, revalidate: 3600, }; } catch (err: unknown) { @@ -205,17 +118,7 @@ export async function getStaticProps(): Promise< ) ) { return { - props: { - highestReputation: [], - longestStreak: [], - highestPostViews: [], - mostUpvoted: [], - mostReferrals: [], - mostReadingDays: [], - highestLevel: [], - isHighestLevelSupported: false, - mostVerifiedUsers: [], - }, + props: { boards: {}, companies: [] }, revalidate: 60, }; }