diff --git a/packages/shared/src/components/streak/StreakShareCard.tsx b/packages/shared/src/components/streak/StreakShareCard.tsx
new file mode 100644
index 00000000000..45b951218c4
--- /dev/null
+++ b/packages/shared/src/components/streak/StreakShareCard.tsx
@@ -0,0 +1,73 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import LogoIcon from '../../svg/LogoIcon';
+import LogoText from '../../svg/LogoText';
+import { ReadingStreakIcon } from '../icons';
+import { IconSize } from '../Icon';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../typography/Typography';
+import type { PublicUserStreak } from '../../graphql/users';
+
+const Stat = ({
+ value,
+ label,
+}: {
+ value: number;
+ label: string;
+}): ReactElement => (
+
+
+ {value}
+
+
+ {label}
+
+
+);
+
+// Rendered headless by the screenshot service (see
+// `webapp/pages/image-generator/streak/[userId]`), so it must stay
+// self-contained: no context, no data fetching, fixed size.
+export function StreakShareCard({
+ streak,
+}: {
+ streak: PublicUserStreak;
+}): ReactElement {
+ return (
+
+
+
+
+
+
+
+
+
+ {streak.current}
+
+
+ day reading streak
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.spec.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.spec.tsx
new file mode 100644
index 00000000000..3d6bdf6d90c
--- /dev/null
+++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.spec.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import type { RenderResult } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { QueryClient } from '@tanstack/react-query';
+import { GrowthBook } from '@growthbook/growthbook-react';
+import { ReadingStreakPopup } from './ReadingStreakPopup';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import type { UserStreak } from '../../../graphql/users';
+import { DayOfWeek } from '../../../lib/date';
+import type { LoggedUser } from '../../../lib/user';
+
+jest.mock('next/router', () => ({
+ useRouter: () => ({ push: jest.fn(), isReady: true, query: {} }),
+}));
+
+jest.mock('../../../graphql/users', () => ({
+ ...jest.requireActual('../../../graphql/users'),
+ getReadingStreak30Days: jest.fn().mockResolvedValue([]),
+}));
+
+jest.mock('../../../hooks/useActions', () => ({
+ useActions: () => ({
+ completeAction: jest.fn(),
+ checkHasCompleted: () => false,
+ isActionsFetched: true,
+ }),
+}));
+
+const user = {
+ id: '1',
+ name: 'Ido',
+ username: 'idoshamun',
+ permalink: 'https://app.daily.dev/idoshamun',
+ timezone: 'Etc/UTC',
+} as unknown as LoggedUser;
+
+const streak: UserStreak = {
+ current: 12,
+ max: 40,
+ total: 120,
+ weekStart: DayOfWeek.Monday,
+ lastViewAt: new Date(),
+};
+
+const renderPopup = ({
+ enabled = false,
+ overrides = {},
+}: {
+ enabled?: boolean;
+ overrides?: Partial;
+} = {}): RenderResult => {
+ const gb = new GrowthBook();
+ gb.setFeatures({
+ sharing_visibility: { defaultValue: enabled },
+ share_streak: { defaultValue: enabled },
+ });
+
+ return render(
+
+
+ ,
+ );
+};
+
+const getSettingsLink = (): HTMLElement =>
+ document.querySelector(
+ 'a[href*="account/customization/streaks"]',
+ )!;
+
+describe('ReadingStreakPopup share action', () => {
+ it('keeps the current UI untouched when the flag is off', async () => {
+ renderPopup();
+
+ expect(await screen.findByText('Current streak')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument();
+ // No wrapper is injected: settings stays a direct child of the action row.
+ expect(getSettingsLink().parentElement?.className).toContain('mt-4');
+ });
+
+ it('renders the share action in the existing row when enabled', async () => {
+ renderPopup({ enabled: true });
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('Share streak')).toBeInTheDocument(),
+ );
+ // Share and settings share one row wrapper, so the popup height is stable.
+ const shareButton = screen.getByLabelText('Share streak');
+ expect(shareButton.parentElement).toContainElement(getSettingsLink());
+ });
+
+ it('hides the share action with nothing to share yet', () => {
+ renderPopup({ enabled: true, overrides: { current: 0 } });
+
+ expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx
index dc1fe8f4006..a008bab29c0 100644
--- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx
+++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx
@@ -47,6 +47,11 @@ import { NotificationSvg } from '../../notifications/NotificationSvg';
import { usePushNotificationContext } from '../../../contexts/PushNotificationContext';
import { IconSize } from '../../Icon';
import { Tooltip } from '../../tooltip/Tooltip';
+import ConditionalWrapper from '../../ConditionalWrapper';
+import { ShareStreakButton } from './ShareStreakButton';
+import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
+import { useSharingVisibility } from '../../../hooks/useSharingVisibility';
+import { featureShareStreak } from '../../../lib/featureManagement';
const getStreak = ({
value,
@@ -136,6 +141,19 @@ export function ReadingStreakPopup({
);
const { onTogglePermission, acceptedJustNow } = usePushNotificationMutation();
+ // Nothing to be proud of at zero, and a share needs a profile to point at.
+ const hasShareableStreak = streak?.current > 0 && !!user?.permalink;
+ const { isEnabled: isSharingVisible } =
+ useSharingVisibility(hasShareableStreak);
+ const { value: isShareStreakEnabled } = useConditionalFeature({
+ feature: featureShareStreak,
+ // Only evaluated once the master gate passes, so control users are never
+ // bucketed into the share_streak experiment.
+ shouldEvaluate: hasShareableStreak && isSharingVisible,
+ });
+ const canShareStreak =
+ hasShareableStreak && isSharingVisible && isShareStreakEnabled;
+
const showAlert =
isPushSupported &&
isAlertShown &&
@@ -303,16 +321,36 @@ export function ReadingStreakPopup({
-
- }
- className={isMobile ? 'w-full' : 'ml-auto'}
- >
- {isMobile ? 'Settings' : null}
-
-
+ (
+ // Share sits inside the existing action row so the popup keeps
+ // its height; on mobile both buttons split the row evenly.
+
+
+ {children}
+
+ )}
+ >
+
+ }
+ className={classNames(
+ isMobile && 'w-full',
+ !isMobile && !canShareStreak && 'ml-auto',
+ )}
+ >
+ {isMobile ? 'Settings' : null}
+
+
+
{showAlert && (
diff --git a/packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx b/packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx
new file mode 100644
index 00000000000..aa38ae4d637
--- /dev/null
+++ b/packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx
@@ -0,0 +1,143 @@
+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 { ShareStreakButton, getStreakShareText } from './ShareStreakButton';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import type { ToastNotification } from '../../../hooks/useToastNotification';
+import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification';
+
+const link = 'https://app.daily.dev/idoshamun';
+const writeText = jest.fn().mockResolvedValue(undefined);
+const share = jest.fn().mockResolvedValue(undefined);
+const canShare = jest.fn();
+// `shouldUseNativeShare` gates on `isMobile()`, which reads this flag.
+const setMobile = () => {
+ globalThis.localStorage.mobile = 'true';
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ delete globalThis.localStorage.mobile;
+ Object.assign(globalThis.navigator, { clipboard: { writeText } });
+ delete (globalThis.navigator as { share?: unknown }).share;
+ delete (globalThis.navigator as { canShare?: unknown }).canShare;
+});
+
+let client: QueryClient;
+
+const renderComponent = (
+ props: Partial[0]> = {},
+): RenderResult => {
+ client = new QueryClient();
+
+ return render(
+
+
+ ,
+ );
+};
+
+const clickShare = async () => {
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Share streak'));
+ });
+};
+
+describe('getStreakShareText', () => {
+ it('sounds like a developer and carries the streak length', () => {
+ expect(getStreakShareText(3)).toBe(
+ '3-day reading streak on daily.dev and counting.',
+ );
+ expect(getStreakShareText(45)).toContain('45 days straight');
+ expect(getStreakShareText(120)).toContain('120 days without breaking');
+ });
+});
+
+describe('ShareStreakButton on desktop', () => {
+ it('copies the link to the clipboard and shows a toast', async () => {
+ renderComponent();
+
+ await clickShare();
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
+ await waitFor(() =>
+ expect(
+ client.getQueryData(TOAST_NOTIF_KEY)?.message,
+ ).toEqual('✅ Copied link to clipboard'),
+ );
+ });
+});
+
+describe('ShareStreakButton on mobile', () => {
+ beforeEach(setMobile);
+
+ it('opens the native share sheet with the streak text and link', async () => {
+ Object.assign(globalThis.navigator, { share });
+
+ renderComponent();
+ await clickShare();
+
+ await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
+ expect(share).toHaveBeenCalledWith({
+ text: `${getStreakShareText(12)}\n${link}`,
+ });
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it('attaches the streak image when the platform can share files', async () => {
+ Object.assign(globalThis.navigator, { share, canShare });
+ canShare.mockReturnValue(true);
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ blob: async () => new Blob(['x'], { type: 'image/png' }),
+ });
+
+ renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' });
+ await clickShare();
+
+ await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
+ const [payload] = share.mock.calls[0];
+ expect(payload.files).toHaveLength(1);
+ expect(payload.files[0].type).toBe('image/png');
+ });
+
+ it('falls back to link + text when file sharing is unsupported', async () => {
+ Object.assign(globalThis.navigator, { share, canShare });
+ canShare.mockReturnValue(false);
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ blob: async () => new Blob(['x'], { type: 'image/png' }),
+ });
+
+ renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' });
+ await clickShare();
+
+ await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
+ expect(share).toHaveBeenCalledWith({
+ text: `${getStreakShareText(12)}\n${link}`,
+ });
+ });
+
+ it('falls back to link + text when navigator.canShare is missing', async () => {
+ Object.assign(globalThis.navigator, { share });
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ blob: async () => new Blob(['x'], { type: 'image/png' }),
+ });
+
+ renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' });
+ await clickShare();
+
+ await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
+ expect(share).toHaveBeenCalledWith({
+ text: `${getStreakShareText(12)}\n${link}`,
+ });
+ });
+});
diff --git a/packages/shared/src/components/streak/popup/ShareStreakButton.tsx b/packages/shared/src/components/streak/popup/ShareStreakButton.tsx
new file mode 100644
index 00000000000..1b7736250df
--- /dev/null
+++ b/packages/shared/src/components/streak/popup/ShareStreakButton.tsx
@@ -0,0 +1,92 @@
+import type { ReactElement } from 'react';
+import React, { useCallback } from 'react';
+import { Button, ButtonVariant } from '../../buttons/Button';
+import { ShareIcon } from '../../icons';
+import { Tooltip } from '../../tooltip/Tooltip';
+import { useShareStreak } from '../../../hooks/streaks/useShareStreak';
+import type { ShareStreakResult } from '../../../hooks/streaks/useShareStreak';
+import { useLogContext } from '../../../contexts/LogContext';
+import { LogEvent, Origin, TargetType } from '../../../lib/log';
+import { ReferralCampaignKey } from '../../../lib/referral';
+
+// Kept short on purpose: on mobile this label sits next to "Settings" in the
+// same row, and anything longer overflows a 375px drawer.
+const LABEL = 'Share streak';
+
+// A streak is a status moment, so the copy carries the number and sounds like
+// the developer who earned it — the link is the footnote, not the headline.
+export const getStreakShareText = (current: number): string => {
+ if (current >= 100) {
+ return `${current} days without breaking my reading streak on daily.dev. Not stopping now.`;
+ }
+
+ if (current >= 30) {
+ return `${current} days straight of reading dev news on daily.dev. The habit stuck.`;
+ }
+
+ return `${current}-day reading streak on daily.dev and counting.`;
+};
+
+interface ShareStreakButtonProps {
+ currentStreak: number;
+ link: string;
+ /**
+ * Generated streak image. Left undefined until the backend screenshot
+ * service renders `/image-generator/streak/[userId]` and exposes the URL —
+ * the share degrades to link + text without it.
+ */
+ imageUrl?: string;
+ showLabel?: boolean;
+ className?: string;
+}
+
+export function ShareStreakButton({
+ currentStreak,
+ link,
+ imageUrl,
+ showLabel,
+ className,
+}: ShareStreakButtonProps): ReactElement {
+ const { logEvent } = useLogContext();
+
+ const onShare = useCallback(
+ ({ provider, withImage }: ShareStreakResult) => {
+ // Same target shape as the milestone share events so analytics can
+ // union streak shares regardless of the surface they came from.
+ logEvent({
+ event_name: LogEvent.ShareLog,
+ target_type: TargetType.StreaksMilestone,
+ target_id: String(currentStreak),
+ extra: JSON.stringify({
+ origin: Origin.ReadingStreak,
+ provider,
+ with_image: withImage,
+ }),
+ });
+ },
+ [currentStreak, logEvent],
+ );
+
+ const { isSharing, onShareStreak } = useShareStreak({
+ link,
+ text: getStreakShareText(currentStreak),
+ cid: ReferralCampaignKey.ShareProfile,
+ imageUrl,
+ onShare,
+ });
+
+ return (
+
+ }
+ aria-label={LABEL}
+ className={className}
+ onClick={onShareStreak}
+ >
+ {showLabel ? LABEL : null}
+
+
+ );
+}
diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts
index 34d47893974..0dad7c978ec 100644
--- a/packages/shared/src/graphql/users.ts
+++ b/packages/shared/src/graphql/users.ts
@@ -657,6 +657,30 @@ export const getReadingStreak = async (): Promise => {
return res.userStreak;
};
+export type PublicUserStreak = Pick;
+
+// Public, by-id counterpart of `USER_STREAK_QUERY`, used to render the
+// shareable streak image outside of an authenticated session.
+export const USER_STREAK_PROFILE_QUERY = gql`
+ query UserStreakProfile($id: ID!) {
+ userStreakProfile(id: $id) {
+ current
+ max
+ total
+ }
+ }
+`;
+
+export const getUserStreakProfile = async (
+ id: string,
+): Promise => {
+ const res = await gqlClient.request<{
+ userStreakProfile: PublicUserStreak | null;
+ }>(USER_STREAK_PROFILE_QUERY, { id });
+
+ return res.userStreakProfile;
+};
+
export const USER_PROFILE_ANALYTICS_QUERY = gql`
query UserProfileAnalytics($userId: ID!) {
userProfileAnalytics(userId: $userId) {
diff --git a/packages/shared/src/hooks/streaks/useShareStreak.ts b/packages/shared/src/hooks/streaks/useShareStreak.ts
new file mode 100644
index 00000000000..813256b00a5
--- /dev/null
+++ b/packages/shared/src/hooks/streaks/useShareStreak.ts
@@ -0,0 +1,128 @@
+import { useCallback, useState } from 'react';
+import { useCopyLink } from '../useCopy';
+import { useGetShortUrl } from '../utils/useGetShortUrl';
+import { ShareProvider } from '../../lib/share';
+import { shouldUseNativeShare } from '../../lib/func';
+import type { ReferralCampaignKey } from '../../lib/referral';
+
+export interface ShareStreakResult {
+ provider: ShareProvider;
+ /** Whether the streak image rode along in the native share sheet. */
+ withImage: boolean;
+}
+
+export interface UseShareStreakProps {
+ link: string;
+ text: string;
+ cid?: ReferralCampaignKey;
+ /**
+ * Rendered streak image (produced by the backend screenshot service from
+ * `/image-generator/streak/[userId]`). Optional on purpose: the image is a
+ * separable upgrade, and the link+text share must keep working whenever the
+ * image is missing, still generating, or the platform can't share files.
+ */
+ imageUrl?: string;
+ imageFileName?: string;
+ onShare?: (result: ShareStreakResult) => void;
+}
+
+export interface UseShareStreak {
+ isSharing: boolean;
+ onShareStreak: () => Promise;
+}
+
+const STREAK_IMAGE_TYPE = 'image/png';
+
+const fetchStreakImage = async (
+ url: string,
+ fileName: string,
+): Promise => {
+ try {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const blob = await response.blob();
+
+ return new File([blob], fileName, { type: blob.type || STREAK_IMAGE_TYPE });
+ } catch (err) {
+ return null;
+ }
+};
+
+// `canShare` is not implemented everywhere `share` is (notably older iOS and
+// several in-app browsers), so treat a missing implementation as "no files".
+const canShareFiles = (files: File[]): boolean => {
+ const nav = globalThis?.navigator;
+
+ return typeof nav?.canShare === 'function' && nav.canShare({ files });
+};
+
+export const useShareStreak = ({
+ link,
+ text,
+ cid,
+ imageUrl,
+ imageFileName = 'daily-dev-streak.png',
+ onShare,
+}: UseShareStreakProps): UseShareStreak => {
+ const [isSharing, setIsSharing] = useState(false);
+ const [, copyLink] = useCopyLink();
+ const { getShortUrl } = useGetShortUrl();
+
+ const onShareStreak = useCallback(async () => {
+ setIsSharing(true);
+
+ try {
+ const shortLink = cid ? await getShortUrl(link, cid) : link;
+
+ if (imageUrl && shouldUseNativeShare()) {
+ const file = await fetchStreakImage(imageUrl, imageFileName);
+
+ if (file && canShareFiles([file])) {
+ try {
+ await navigator.share({
+ text: `${text}\n${shortLink}`,
+ files: [file],
+ });
+ onShare?.({ provider: ShareProvider.Native, withImage: true });
+
+ return;
+ } catch (err) {
+ // User dismissed the sheet or the platform refused the payload —
+ // fall through to the link share rather than dead-ending.
+ }
+ }
+ }
+
+ if (shouldUseNativeShare()) {
+ try {
+ await navigator.share({ text: `${text}\n${shortLink}` });
+ onShare?.({ provider: ShareProvider.Native, withImage: false });
+ } catch (err) {
+ // Sharing was dismissed; nothing to report.
+ }
+
+ return;
+ }
+
+ await copyLink({ link: shortLink });
+ onShare?.({ provider: ShareProvider.CopyLink, withImage: false });
+ } finally {
+ setIsSharing(false);
+ }
+ }, [
+ cid,
+ copyLink,
+ getShortUrl,
+ imageFileName,
+ imageUrl,
+ link,
+ onShare,
+ text,
+ ]);
+
+ return { isSharing, onShareStreak };
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 97b38cbdd2b..27a78088cfc 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -308,3 +308,9 @@ export const featureSharingVisibility = new Feature(
// high-traffic icon, so it ramps on its own flag to watch share/copy metrics.
// Keep the default `false` (control = existing `LinkIcon`).
export const featureShareCopyIcon = new Feature('share_copy_icon', false);
+
+// Adds a "Share my streak" action to the reading-streak popup action row. A
+// streak is a status moment, so this ramps separately from the generic
+// copy-link surfaces to watch whether identity shares convert differently.
+// Keep the default `false` (control = the popup without a share action).
+export const featureShareStreak = new Feature('share_streak', false);
diff --git a/packages/storybook/stories/components/ShareStreakButton.stories.tsx b/packages/storybook/stories/components/ShareStreakButton.stories.tsx
new file mode 100644
index 00000000000..f4207846ee0
--- /dev/null
+++ b/packages/storybook/stories/components/ShareStreakButton.stories.tsx
@@ -0,0 +1,130 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ ShareStreakButton,
+ getStreakShareText,
+} from '@dailydotdev/shared/src/components/streak/popup/ShareStreakButton';
+import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext';
+import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
+import type { LoggedUser } from '@dailydotdev/shared/src/lib/user';
+import { fn } from 'storybook/test';
+
+const permalink = 'https://app.daily.dev/idoshamun';
+
+const meta: Meta = {
+ title: 'Components/Share/ShareStreakButton',
+ component: ShareStreakButton,
+ args: {
+ currentStreak: 12,
+ link: permalink,
+ },
+ argTypes: {
+ currentStreak: { control: 'number' },
+ showLabel: {
+ control: 'boolean',
+ description: 'Mobile: the action row shows the label next to the icon',
+ },
+ imageUrl: {
+ control: 'text',
+ description:
+ 'Generated streak image. When set and the platform supports `navigator.canShare({ files })`, the native sheet carries the image instead of just the link.',
+ },
+ },
+ decorators: [
+ (Story) => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ const LogContext = getLogContextStatic();
+
+ const mockUser = {
+ id: '1',
+ name: 'Ido Shamun',
+ username: 'idoshamun',
+ email: 'ido@daily.dev',
+ image:
+ 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg',
+ providers: ['google'],
+ createdAt: '2024-01-01T00:00:00.000Z',
+ permalink,
+ } as unknown as LoggedUser;
+
+ return (
+
+
+ false,
+ }}
+ >
+
+
+
+
+
+
+ );
+ },
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+// Early streak — the copy stays matter-of-fact.
+export const ShortStreak: Story = {
+ args: { currentStreak: 5 },
+ parameters: { docs: { description: { story: getStreakShareText(5) } } },
+};
+
+// The habit stuck: copy switches at 30 days.
+export const LongStreak: Story = {
+ args: { currentStreak: 45 },
+ parameters: { docs: { description: { story: getStreakShareText(45) } } },
+};
+
+// Milestone territory: copy switches again at 100 days.
+export const MilestoneStreak: Story = {
+ args: { currentStreak: 180 },
+ parameters: { docs: { description: { story: getStreakShareText(180) } } },
+};
+
+// Mobile action row: icon + label, full width next to Settings.
+export const WithLabel: Story = {
+ args: { currentStreak: 45, showLabel: true, className: 'w-full' },
+};
+
+// With a generated image available, the native sheet carries the picture
+// (mobile + `navigator.canShare({ files })` only) and otherwise degrades to
+// the link + text share.
+export const WithStreakImage: Story = {
+ args: {
+ currentStreak: 45,
+ imageUrl: 'https://media.daily.dev/image/upload/streak-placeholder.png',
+ },
+};
diff --git a/packages/storybook/stories/components/StreakShareCard.stories.tsx b/packages/storybook/stories/components/StreakShareCard.stories.tsx
new file mode 100644
index 00000000000..a9a0cd088aa
--- /dev/null
+++ b/packages/storybook/stories/components/StreakShareCard.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { StreakShareCard } from '@dailydotdev/shared/src/components/streak/StreakShareCard';
+
+// What the screenshot service renders at
+// `/image-generator/streak/[userId]` once the route is wired on the backend.
+const meta: Meta = {
+ title: 'Components/Share/StreakShareCard',
+ component: StreakShareCard,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ShortStreak: Story = {
+ args: { streak: { current: 5, max: 9, total: 24 } },
+};
+
+export const LongStreak: Story = {
+ args: { streak: { current: 45, max: 45, total: 210 } },
+};
+
+export const MilestoneStreak: Story = {
+ args: { streak: { current: 180, max: 180, total: 640 } },
+};
diff --git a/packages/webapp/pages/image-generator/streak/[userId].tsx b/packages/webapp/pages/image-generator/streak/[userId].tsx
new file mode 100644
index 00000000000..e844859cfd6
--- /dev/null
+++ b/packages/webapp/pages/image-generator/streak/[userId].tsx
@@ -0,0 +1,44 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type {
+ GetStaticPathsResult,
+ GetStaticPropsContext,
+ GetStaticPropsResult,
+} from 'next';
+import { StreakShareCard } from '@dailydotdev/shared/src/components/streak/StreakShareCard';
+import type { PublicUserStreak } from '@dailydotdev/shared/src/graphql/users';
+import { getUserStreakProfile } from '@dailydotdev/shared/src/graphql/users';
+
+export async function getStaticPaths(): Promise {
+ return { paths: [], fallback: 'blocking' };
+}
+
+interface PageProps {
+ streak: PublicUserStreak;
+}
+
+export async function getStaticProps({
+ params,
+}: GetStaticPropsContext): Promise> {
+ const userId = params?.userId as string;
+
+ if (!userId) {
+ return { notFound: true, revalidate: false };
+ }
+
+ const streak = await getUserStreakProfile(userId);
+
+ if (!streak) {
+ return { notFound: true, revalidate: false };
+ }
+
+ return { props: { streak }, revalidate: 60 };
+}
+
+const StreakImagePage = ({ streak }: PageProps): ReactElement => (
+
+
+
+);
+
+export default StreakImagePage;