From 75970172ddea1546efe8c1e09aca63c9fff46a2e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 17:56:46 +0300 Subject: [PATCH 1/3] feat(share): celebrations, achievements & game center share (PR 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-achievement share/download, a peak-moment share on the two celebration modals, and a page-level share on the profile achievements tab — all behind `share_celebrations` + the `sharing_visibility` master kill-switch. Control renders every surface exactly as it does today. - add the achievement image-generator page (screenshot_wrapper + ISR) and the AchievementShareCard it renders - add AchievementShareActions (badge download + ShareActions) and wire it into AchievementCard (opt-in prop), the unlock modal and the showcase picker - add a share row to the streak milestone modal - add a page-level share to ProfileAchievements - add share_celebrations flag, useShareCelebrations gate, log events Co-Authored-By: Claude Opus 4.8 --- .../modals/AchievementCompletionModal.tsx | 16 +- .../modals/AchievementShowcaseModal.tsx | 34 +++- .../modals/streaks/NewStreakModal.spec.tsx | 57 ++++++ .../modals/streaks/NewStreakModal.tsx | 48 ++++- .../achievements/AchievementCard.spec.tsx | 66 ++++++- .../achievements/AchievementCard.tsx | 64 +++++-- .../AchievementShareActions.spec.tsx | 145 ++++++++++++++ .../achievements/AchievementShareActions.tsx | 110 +++++++++++ .../achievements/AchievementShareCard.tsx | 120 ++++++++++++ .../achievements/AchievementsList.tsx | 1 + .../achievements/ProfileAchievements.spec.tsx | 109 +++++++++++ .../achievements/ProfileAchievements.tsx | 36 ++++ .../achievements/achievementShare.ts | 23 +++ .../src/hooks/useShareCelebrations.spec.tsx | 104 ++++++++++ .../shared/src/hooks/useShareCelebrations.ts | 17 ++ packages/shared/src/lib/featureManagement.ts | 10 + packages/shared/src/lib/log.ts | 5 + .../components/AchievementShare.stories.tsx | 179 ++++++++++++++++++ .../achievement/[...params].tsx | 74 ++++++++ 19 files changed, 1192 insertions(+), 26 deletions(-) create mode 100644 packages/shared/src/components/modals/streaks/NewStreakModal.spec.tsx create mode 100644 packages/shared/src/features/profile/components/achievements/AchievementShareActions.spec.tsx create mode 100644 packages/shared/src/features/profile/components/achievements/AchievementShareActions.tsx create mode 100644 packages/shared/src/features/profile/components/achievements/AchievementShareCard.tsx create mode 100644 packages/shared/src/features/profile/components/achievements/ProfileAchievements.spec.tsx create mode 100644 packages/shared/src/features/profile/components/achievements/achievementShare.ts create mode 100644 packages/shared/src/hooks/useShareCelebrations.spec.tsx create mode 100644 packages/shared/src/hooks/useShareCelebrations.ts create mode 100644 packages/storybook/stories/components/AchievementShare.stories.tsx create mode 100644 packages/webapp/pages/image-generator/achievement/[...params].tsx diff --git a/packages/shared/src/components/modals/AchievementCompletionModal.tsx b/packages/shared/src/components/modals/AchievementCompletionModal.tsx index 4c4a092daff..aebfd4d9364 100644 --- a/packages/shared/src/components/modals/AchievementCompletionModal.tsx +++ b/packages/shared/src/components/modals/AchievementCompletionModal.tsx @@ -10,7 +10,7 @@ import { useActions } from '../../hooks/useActions'; import { useProfileAchievements } from '../../hooks/profile/useProfileAchievements'; import { useTrackedAchievement } from '../../hooks/profile/useTrackedAchievement'; import { ActionType } from '../../graphql/actions'; -import { LogEvent, TargetType } from '../../lib/log'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; import type { LazyModalCommonProps, ModalProps } from './common/Modal'; import { Modal } from './common/Modal'; import { ModalClose } from './common/ModalClose'; @@ -23,6 +23,8 @@ import { import { Checkbox } from '../fields/Checkbox'; import { getTargetCount } from '../../graphql/user/achievements'; import { sortLockedAchievements } from './achievement/sortAchievements'; +import { useShareCelebrations } from '../../hooks/useShareCelebrations'; +import { AchievementShareActions } from '../../features/profile/components/achievements/AchievementShareActions'; const SPARKLE_DURATION_MS = 4500; @@ -66,6 +68,7 @@ export const AchievementCompletionModal = ({ } }; + const isShareEnabled = useShareCelebrations(); const [phase, setPhase] = useState<'celebrate' | 'pickNext'>('celebrate'); const [isTracking, setIsTracking] = useState(false); const [showSparkles, setShowSparkles] = useState(true); @@ -211,6 +214,17 @@ export const AchievementCompletionModal = ({
+{unlockedAchievement.achievement.points} points
+ {isShareEnabled && ( + + )} ); + + if (!isShareEnabled) { + return ( + + {row} + + ); + } + + // Rendered next to the toggle, never inside it — a button nested + // in a button is invalid and would swallow the row selection. + return ( +
+ {row} + +
+ ); })} )} diff --git a/packages/shared/src/components/modals/streaks/NewStreakModal.spec.tsx b/packages/shared/src/components/modals/streaks/NewStreakModal.spec.tsx new file mode 100644 index 00000000000..c7a608be80d --- /dev/null +++ b/packages/shared/src/components/modals/streaks/NewStreakModal.spec.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import NewStreakModal from './NewStreakModal'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { useShareCelebrations } from '../../../hooks/useShareCelebrations'; + +jest.mock('../../../hooks/useShareCelebrations', () => ({ + __esModule: true, + useShareCelebrations: jest.fn(), +})); + +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useActions: () => ({ + completeAction: jest.fn(), + checkHasCompleted: () => false, + isActionsFetched: true, + }), +})); + +const useShareCelebrationsMock = useShareCelebrations as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + useShareCelebrationsMock.mockReturnValue(false); +}); + +const renderModal = () => + render( + + + , + ); + +describe('NewStreakModal — share celebration', () => { + it('renders the milestone with no share control when the flag is off', () => { + renderModal(); + + expect(screen.getByText('10 days streak')).toBeInTheDocument(); + expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument(); + expect(screen.queryByText('Share your streak')).not.toBeInTheDocument(); + }); + + it('renders exactly one share control when the flag is on', () => { + useShareCelebrationsMock.mockReturnValue(true); + renderModal(); + + expect(screen.getAllByLabelText('Share streak')).toHaveLength(1); + expect(screen.getByText('Share your streak')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx index 1f79b0784a1..bd2c74d60d4 100644 --- a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx +++ b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx @@ -12,12 +12,22 @@ import { } from '../../../lib/image'; import type { StreakModalProps } from './common'; import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent, TargetType } from '../../../lib/log'; +import { LogEvent, Origin, TargetType } from '../../../lib/log'; import { generateQueryKey, RequestKey } from '../../../lib/query'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import StreakReminderSwitch from '../../streak/StreakReminderSwitch'; +import { ShareActions } from '../../share/ShareActions'; +import { ButtonVariant } from '../../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { useShareCelebrations } from '../../../hooks/useShareCelebrations'; +import { webappUrl } from '../../../lib/constants'; +import { ReferralCampaignKey } from '../../../lib/referral'; const Paragraph = classed('p', 'text-center text-text-tertiary'); @@ -37,6 +47,7 @@ export default function NewStreakModal({ const shouldShowSplash = currentStreak >= maxStreak; const daysPlural = currentStreak === 1 ? 'day' : 'days'; const loggedImpression = useRef(false); + const isShareEnabled = useShareCelebrations(); useEffect(() => { if (loggedImpression.current) { @@ -120,9 +131,42 @@ export default function NewStreakModal({ ? 'Epic win! You are in a league of your own' : `New milestone reached! You are unstoppable.`} + {isShareEnabled && ( +
+ + Share your streak + + + logEvent({ + event_name: LogEvent.ShareStreakMilestone, + target_type: TargetType.StreaksMilestone, + target_id: currentStreak?.toString(), + extra: JSON.stringify({ + origin: Origin.ReadingStreak, + provider, + }), + }) + } + /> +
+ )} diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx index f135a067809..d636774de84 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.spec.tsx @@ -1,9 +1,22 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../../__tests__/helpers/boot'; import type { UserAchievement } from '../../../../graphql/user/achievements'; import { AchievementType } from '../../../../graphql/user/achievements'; import { AchievementCard } from './AchievementCard'; +import { useShareCelebrations } from '../../../../hooks/useShareCelebrations'; + +jest.mock('../../../../hooks/useShareCelebrations', () => ({ + __esModule: true, + useShareCelebrations: jest.fn(), +})); + +const useShareCelebrationsMock = useShareCelebrations as jest.Mock; + +beforeEach(() => { + useShareCelebrationsMock.mockReturnValue(false); +}); const createLockedAchievement = ( overrides: Partial = {}, @@ -34,7 +47,7 @@ const renderCard = ( }); return render( - + - , + , ); }; @@ -128,3 +141,50 @@ describe('AchievementCard — stop tracking', () => { ).not.toBeInTheDocument(); }); }); + +describe('AchievementCard — share celebrations', () => { + const unlocked = createLockedAchievement({ + unlockedAt: '2026-05-04T10:00:00.000Z', + progress: 1, + }); + const shareUser = { username: 'ada', name: 'Ada Lovelace' }; + + it('renders no share or download control when the flag is off', () => { + renderCard({ userAchievement: unlocked, shareUser }); + + expect( + screen.queryByLabelText('Share this achievement'), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Download badge')).not.toBeInTheDocument(); + expect(screen.getByText(/^Unlocked/)).toBeInTheDocument(); + }); + + it('renders exactly one share and one download control when the flag is on', () => { + useShareCelebrationsMock.mockReturnValue(true); + renderCard({ userAchievement: unlocked, shareUser }); + + expect(screen.getAllByLabelText('Share this achievement')).toHaveLength(1); + expect(screen.getAllByLabelText('Download badge')).toHaveLength(1); + expect(screen.getByText(/^Unlocked/)).toBeInTheDocument(); + }); + + it('renders no share control without a shareUser, even with the flag on', () => { + useShareCelebrationsMock.mockReturnValue(true); + renderCard({ userAchievement: unlocked }); + + expect( + screen.queryByLabelText('Share this achievement'), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Download badge')).not.toBeInTheDocument(); + }); + + it('renders no share control for a locked achievement', () => { + useShareCelebrationsMock.mockReturnValue(true); + renderCard({ shareUser }); + + expect( + screen.queryByLabelText('Share this achievement'), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Download badge')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx index e04f8eaedee..1eff767b3ea 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx @@ -23,12 +23,15 @@ import { ProgressBar } from '../../../../components/fields/ProgressBar'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { anchorDefaultRel } from '../../../../lib/strings'; import { PinIcon } from '../../../../components/icons'; +import { useShareCelebrations } from '../../../../hooks/useShareCelebrations'; +import { Origin } from '../../../../lib/log'; import { AchievementRarityTier, getAchievementRarityTier, rarityGlowClasses, } from './achievementRarity'; import { RaritySparkles } from './RaritySparkles'; +import { AchievementShareActions } from './AchievementShareActions'; interface AchievementCardProps { userAchievement: UserAchievement; @@ -38,6 +41,12 @@ interface AchievementCardProps { onTrack?: (achievementId: string) => Promise; onUntrack?: () => Promise; isUntrackPending?: boolean; + /** + * Profile the card belongs to. Opt-in: only the achievements tab passes it, + * so the many other consumers (game center, widgets, tracker hover card) keep + * their current DOM. + */ + shareUser?: { username?: string; name?: string }; } export function AchievementCard({ @@ -48,6 +57,7 @@ export function AchievementCard({ onTrack, onUntrack, isUntrackPending = false, + shareUser, }: AchievementCardProps): ReactElement { const { achievement, progress, unlockedAt } = userAchievement; const targetCount = getTargetCount(achievement); @@ -62,6 +72,28 @@ export function AchievementCard({ rarityTier === AchievementRarityTier.Emerald ? '<1%' : `${Math.round(achievement.rarity ?? 0)}%`; + const isShareEnabled = useShareCelebrations(!!shareUser && isUnlocked); + const showShare = isShareEnabled && !!shareUser && isUnlocked; + const unlockedMeta = unlockedAt ? ( + <> + + Unlocked {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + + {achievement.rarity != null && ( + + Earned by {rarityLabel} of users + + )} + + ) : null; + return (
)} - {isUnlocked && unlockedAt && ( -
- - Unlocked{' '} - {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} - - {achievement.rarity != null && ( - - Earned by {rarityLabel} of users - - )} + {isUnlocked && unlockedAt && !showShare && ( +
{unlockedMeta}
+ )} + + {isUnlocked && unlockedAt && showShare && ( +
+
{unlockedMeta}
+
)}
diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShareActions.spec.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShareActions.spec.tsx new file mode 100644 index 00000000000..2483fcb2ec0 --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/AchievementShareActions.spec.tsx @@ -0,0 +1,145 @@ +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 { AchievementShareActions } from './AchievementShareActions'; +import { AchievementType } from '../../../../graphql/user/achievements'; +import { TestBootProvider } from '../../../../../__tests__/helpers/boot'; +import { Origin } from '../../../../lib/log'; +import { useViewSize } from '../../../../hooks/useViewSize'; +import { downloadUrl } from '../../../../lib/blob'; +import { shouldUseNativeShare } from '../../../../lib/func'; + +jest.mock('../../../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +jest.mock('../../../../lib/func', () => { + const actual = jest.requireActual('../../../../lib/func'); + return { __esModule: true, ...actual, shouldUseNativeShare: jest.fn() }; +}); + +jest.mock('../../../../lib/blob', () => ({ + __esModule: true, + downloadUrl: jest.fn().mockResolvedValue(undefined), +})); + +const mockDisplayToast = jest.fn(); +jest.mock('../../../../hooks/useToastNotification', () => ({ + ...jest.requireActual('../../../../hooks/useToastNotification'), + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +const useViewSizeMock = useViewSize as jest.Mock; +const downloadUrlMock = downloadUrl as jest.Mock; +const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); + +const achievement = { + id: 'ach-1', + name: 'Night owl', + description: 'Read 100 posts after midnight', + image: 'https://daily.dev/achievement.png', + type: AchievementType.Milestone, + criteria: { targetCount: 100 }, + points: 250, + rarity: 3, + unit: null, +}; + +beforeEach(() => { + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); + shouldUseNativeShareMock.mockReturnValue(false); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderActions = ( + props: Partial> = {}, +): RenderResult => + render( + + + , + ); + +describe('AchievementShareActions', () => { + it('renders exactly one download control and one share control', () => { + renderActions(); + + expect(screen.getAllByLabelText('Download badge')).toHaveLength(1); + expect(screen.getAllByLabelText('Share this achievement')).toHaveLength(1); + }); + + it('downloads the achievement badge image', async () => { + renderActions(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Download badge')); + }); + + await waitFor(() => + expect(downloadUrlMock).toHaveBeenCalledWith({ + url: achievement.image, + filename: 'Night owl.png', + }), + ); + }); + + it('copies the achievement link and toasts when native share is unavailable', async () => { + useViewSizeMock.mockReturnValue(false); // mobile + renderActions(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share this achievement')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('ada/achievements'), + ), + ); + expect(mockDisplayToast).toHaveBeenCalledWith( + '✅ Copied link to clipboard', + expect.anything(), + ); + }); + + it('uses the native share sheet on mobile when available', async () => { + useViewSizeMock.mockReturnValue(false); + shouldUseNativeShareMock.mockReturnValue(true); + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share }); + renderActions(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share this achievement')); + }); + + await waitFor(() => expect(share).toHaveBeenCalled()); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('omits the share control when the profile has no username', () => { + renderActions({ username: undefined }); + + expect( + screen.queryByLabelText('Share this achievement'), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText('Download badge')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShareActions.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShareActions.tsx new file mode 100644 index 00000000000..5b77fc2aea7 --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/AchievementShareActions.tsx @@ -0,0 +1,110 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import { useMutation } from '@tanstack/react-query'; +import type { Achievement } from '../../../../graphql/user/achievements'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { DownloadIcon } from '../../../../components/icons'; +import { Tooltip } from '../../../../components/tooltip/Tooltip'; +import { ShareActions } from '../../../../components/share/ShareActions'; +import { useLogContext } from '../../../../contexts/LogContext'; +import type { Origin } from '../../../../lib/log'; +import { LogEvent, TargetType } from '../../../../lib/log'; +import { downloadUrl } from '../../../../lib/blob'; +import { ReferralCampaignKey } from '../../../../lib/referral'; +import type { ShareProvider } from '../../../../lib/share'; +import { + getAchievementDownloadFilename, + getAchievementShareLink, + getAchievementShareText, +} from './achievementShare'; + +export interface AchievementShareActionsProps { + achievement: Achievement; + /** Profile the achievement belongs to — the share link points at it. */ + username?: string; + name?: string; + isOwner?: boolean; + origin: Origin; + /** Peak-end moments (unlock modal) get a labelled button, lists get icons. */ + withLabels?: boolean; + buttonSize?: ButtonSize; + className?: string; +} + +export function AchievementShareActions({ + achievement, + username, + name, + isOwner = false, + origin, + withLabels = false, + buttonSize = ButtonSize.Small, + className, +}: AchievementShareActionsProps): ReactElement { + const { logEvent } = useLogContext(); + const { mutateAsync: download, isPending: isDownloading } = useMutation({ + mutationFn: downloadUrl, + }); + + const logShare = useCallback( + (event_name: LogEvent, provider?: ShareProvider) => + logEvent({ + event_name, + target_type: TargetType.AchievementCard, + target_id: achievement.id, + extra: JSON.stringify({ origin, ...(provider && { provider }) }), + }), + [achievement.id, logEvent, origin], + ); + + const onDownload = useCallback(async () => { + await download({ + url: achievement.image, + filename: getAchievementDownloadFilename(achievement), + }); + logShare(LogEvent.DownloadAchievement); + }, [achievement, download, logShare]); + + const downloadLabel = 'Download badge'; + + return ( +
+ + + + {!!username && ( + logShare(LogEvent.ShareAchievement, provider)} + /> + )} +
+ ); +} diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShareCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShareCard.tsx new file mode 100644 index 00000000000..a00b5317ed7 --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/AchievementShareCard.tsx @@ -0,0 +1,120 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { UserAchievement } from '../../../../graphql/user/achievements'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import LogoIcon from '../../../../svg/LogoIcon'; +import LogoText from '../../../../svg/LogoText'; +import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; +import { + AchievementRarityTier, + getAchievementRarityTier, + rarityGlowClasses, +} from './achievementRarity'; + +export interface AchievementShareCardUser { + name?: string | null; + username?: string | null; + image?: string | null; +} + +export interface AchievementShareCardProps { + userAchievement: UserAchievement; + user: AchievementShareCardUser; +} + +// Static, self-contained render of a single unlocked achievement. Used by the +// `/image-generator/achievement/...` page that the backend screenshot service +// turns into a PNG, so it must not depend on viewport, hover or client-only +// data — everything it needs arrives as props. +export function AchievementShareCard({ + userAchievement, + user, +}: AchievementShareCardProps): ReactElement { + const { achievement, unlockedAt } = userAchievement; + const rarityTier = getAchievementRarityTier(achievement.rarity); + const rarityLabel = + rarityTier === AchievementRarityTier.Emerald + ? '<1%' + : `${Math.round(achievement.rarity ?? 0)}%`; + + return ( +
+ {achievement.name} +
+ + {achievement.name} + + + {achievement.description} + +
+ +
+ + {achievement.points} points + + {achievement.rarity != null && ( + + Earned by {rarityLabel} + + )} +
+ +
+
+ {!!user.image && ( + {user.name + )} + + {user.name ?? `@${user.username}`} + +
+ {!!unlockedAt && ( + + Unlocked{' '} + {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + + )} +
+ + +
+
+
+ ); +} diff --git a/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx b/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx index 8d71b630627..095673270b5 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx @@ -279,6 +279,7 @@ export function AchievementsList({ onTrack={canTrackAchievements ? handleTrack : undefined} onUntrack={canTrackAchievements ? handleUntrack : undefined} isUntrackPending={isUntrackPending} + shareUser={user} /> ))}
diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievements.spec.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievements.spec.tsx new file mode 100644 index 00000000000..2f1c26f08f2 --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievements.spec.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { ProfileAchievements } from './ProfileAchievements'; +import { AchievementType } from '../../../../graphql/user/achievements'; +import type { UserAchievement } from '../../../../graphql/user/achievements'; +import type { PublicProfile } from '../../../../lib/user'; +import { TestBootProvider } from '../../../../../__tests__/helpers/boot'; +import { useShareCelebrations } from '../../../../hooks/useShareCelebrations'; +import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements'; + +jest.mock('../../../../hooks/useShareCelebrations', () => ({ + __esModule: true, + useShareCelebrations: jest.fn(), +})); + +jest.mock('../../../../hooks/profile/useProfileAchievements', () => ({ + __esModule: true, + useProfileAchievements: jest.fn(), +})); + +// The list is exercised by its own specs; stubbing it keeps this spec focused +// on the page-level share control. +jest.mock('./AchievementsList', () => ({ + __esModule: true, + AchievementsList: () =>
, +})); + +const useShareCelebrationsMock = useShareCelebrations as jest.Mock; +const useProfileAchievementsMock = useProfileAchievements as jest.Mock; + +const userAchievement: UserAchievement = { + achievement: { + id: 'ach1', + name: 'First Post', + description: 'Publish your first post', + image: 'https://daily.dev/achievement.png', + type: AchievementType.Milestone, + criteria: { targetCount: 1 }, + points: 10, + rarity: null, + unit: null, + }, + progress: 1, + unlockedAt: '2026-05-04T10:00:00.000Z', + createdAt: null, + updatedAt: null, +}; + +const profile = { + id: 'u1', + name: 'Ada Lovelace', + username: 'ada', + createdAt: '2024-01-01T00:00:00.000Z', +} as PublicProfile; + +beforeEach(() => { + jest.clearAllMocks(); + useShareCelebrationsMock.mockReturnValue(false); + useProfileAchievementsMock.mockReturnValue({ + achievements: [userAchievement], + unlockedCount: 1, + totalCount: 1, + totalPoints: 10, + isPending: false, + isError: false, + }); +}); + +const renderPage = () => + render( + + + , + ); + +describe('ProfileAchievements — page share', () => { + it('renders no share control when the flag is off', () => { + renderPage(); + + expect( + screen.queryByLabelText('Share achievements'), + ).not.toBeInTheDocument(); + }); + + it('renders exactly one page-level share control when the flag is on', () => { + useShareCelebrationsMock.mockReturnValue(true); + renderPage(); + + expect(screen.getAllByLabelText('Share achievements')).toHaveLength(1); + }); + + it('renders no share control while achievements are loading', () => { + useShareCelebrationsMock.mockReturnValue(true); + useProfileAchievementsMock.mockReturnValue({ + achievements: undefined, + unlockedCount: 0, + totalCount: 0, + totalPoints: 0, + isPending: true, + isError: false, + }); + renderPage(); + + expect( + screen.queryByLabelText('Share achievements'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx index 0ef65282df8..23f45798812 100644 --- a/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievements.tsx @@ -21,6 +21,12 @@ import { useConditionalFeature } from '../../../../hooks/useConditionalFeature'; import { achievementTrackingWidgetFeature } from '../../../../lib/featureManagement'; import { shouldShowAchievementTracker } from '../../../../lib/achievements'; import { useLayoutVariant } from '../../../../hooks/layout/useLayoutVariant'; +import { useShareCelebrations } from '../../../../hooks/useShareCelebrations'; +import { ShareActions } from '../../../../components/share/ShareActions'; +import { useLogContext } from '../../../../contexts/LogContext'; +import { LogEvent, Origin } from '../../../../lib/log'; +import { ReferralCampaignKey } from '../../../../lib/referral'; +import { getAchievementShareLink } from './achievementShare'; const AchievementTrackingWidget = dynamic(() => import('../ProfileWidgets/AchievementTrackingWidget').then( @@ -88,6 +94,13 @@ export function ProfileAchievements({ !isAchievementTrackingWidgetLoading && shouldRenderTrackingWidget; const { openModal } = useLazyModal(); + const { logEvent } = useLogContext(); + const hasAchievements = + !isPending && !isError && !!achievements?.length && !!user.username; + const isShareEnabled = useShareCelebrations(hasAchievements); + const shareLink = user.username + ? getAchievementShareLink(user.username) + : null; if (isPending) { return ( @@ -163,6 +176,29 @@ export function ProfileAchievements({ > ({unlockedCount}/{totalCount}) + {isShareEnabled && !!shareLink && ( + + logEvent({ + event_name: LogEvent.ShareAchievements, + target_id: user.id, + extra: JSON.stringify({ + origin: Origin.Achievements, + provider, + }), + }) + } + /> + )}
{loggedUser && !isOwner && (