)}
- {isUnlocked && unlockedAt && (
-
-
- Unlocked{' '}
- {formatDate({ value: unlockedAt, type: TimeFormatType.Post })}
-
- {achievement.rarity != null && (
-
- Earned by {rarityLabel} of users
-
- )}
+ {isUnlocked && unlockedAt && !showShare && (
+
{unlockedMeta}
+ )}
+
+ {isUnlocked && unlockedAt && showShare && (
+
)}
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 (
+
+
+ }
+ aria-label={downloadLabel}
+ loading={isDownloading}
+ disabled={!achievement.image}
+ onClick={() => onDownload()}
+ >
+ {withLabels ? downloadLabel : undefined}
+
+
+ {!!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.description}
+
+
+
+
+
+ {achievement.points} points
+
+ {achievement.rarity != null && (
+
+ Earned by {rarityLabel}
+
+ )}
+
+
+
+
+ {!!user.image && (
+

+ )}
+
+ {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: () =>