diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2..0085729108 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/copy-link action next to "Download DevCard" in the DevCard +// customization step, so the card can be shared without downloading it first. +// Control keeps the download-only action row. Keep the default `false` — +// GrowthBook ramps it. +export const featureShareDevcard = new Feature('share_devcard', false); diff --git a/packages/storybook/stories/components/DevCardShareActions.stories.tsx b/packages/storybook/stories/components/DevCardShareActions.stories.tsx new file mode 100644 index 0000000000..63da50b57e --- /dev/null +++ b/packages/storybook/stories/components/DevCardShareActions.stories.tsx @@ -0,0 +1,164 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { Button } from '@dailydotdev/shared/src/components/buttons/Button'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/common'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { fn } from 'storybook/test'; + +const permalink = 'https://app.daily.dev/testuser'; + +interface DevCardActionRowProps { + /** + * Mirrors `share_devcard` + the `sharing_visibility` master gate in + * `DevCardStep2`. Off = the download-only action row currently in production. + */ + canShare: boolean; + onShare: () => void; +} + +// Mirrors the action row under the DevCard preview in +// `packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx`. +// The step itself lives in `webapp`, which storybook does not depend on, so the +// row is reproduced here to review spacing and the share affordance. +const DevCardActionRow = ({ + canShare, + onShare, +}: DevCardActionRowProps): React.ReactElement => { + const downloadButton = ( + + ); + + if (!canShare) { + return
{downloadButton}
; + } + + return ( +
+
+ {downloadButton} + +
+
+ ); +}; + +const meta: Meta = { + title: 'Components/Share/DevCardShareActions', + component: DevCardActionRow, + args: { + canShare: true, + onShare: fn(), + }, + argTypes: { + canShare: { + control: 'boolean', + description: + 'share_devcard flag + sharing_visibility master gate. Off = download only.', + }, + }, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Mock the short-URL resolution so copy/social actions don't hit network. + queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123'); + + const LogContext = getLogContextStatic(); + + const mockUser = { + id: '1', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + 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; + +// Flag on, desktop: primary Download plus a secondary share trigger that opens +// the share popover. Click the share icon, then "Copy link" for the copying +// state (the icon flips to its filled variant while the toast is up). +export const Desktop: Story = {}; + +// Flag off — the production action row: a single centered Download button. +export const FlagOff: Story = { + name: 'Flag off (download only)', + args: { canShare: false }, +}; + +// Narrow canvas so the mobile breakpoint applies: a single tap goes straight to +// the native share sheet (or copies when native share is unavailable). +export const Mobile: Story = { + name: 'Mobile viewport (tap to share)', + parameters: { viewport: { defaultViewport: 'mobile2' } }, +}; diff --git a/packages/webapp/__tests__/DevCardStep2.spec.tsx b/packages/webapp/__tests__/DevCardStep2.spec.tsx new file mode 100644 index 0000000000..19d3041083 --- /dev/null +++ b/packages/webapp/__tests__/DevCardStep2.spec.tsx @@ -0,0 +1,221 @@ +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 nock from 'nock'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; +import { + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks/useViewSize'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import type { Feature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { downloadUrl } from '@dailydotdev/shared/src/lib/blob'; +import { GENERATE_DEVCARD_MUTATION } from '../graphql/devcard'; +import { DevCardStep2 } from '../components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2'; + +jest.mock('react-parallax-tilt', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock( + '@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper', + () => ({ + DevCardFetchWrapper: () =>
, + }), +); + +jest.mock('@dailydotdev/shared/src/hooks/profile/useDevCard', () => ({ + useDevCard: () => ({ + devcard: { theme: 'default', showBorder: true, isProfileCover: false }, + isLoading: false, + coverImage: 'https://daily.dev/cover.png', + }), +})); + +jest.mock('@dailydotdev/shared/src/hooks/useViewSize', () => { + const actual = jest.requireActual( + '@dailydotdev/shared/src/hooks/useViewSize', + ); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +jest.mock('@dailydotdev/shared/src/hooks/useToastNotification', () => { + const actual = jest.requireActual( + '@dailydotdev/shared/src/hooks/useToastNotification', + ); + return { __esModule: true, ...actual, useToastNotification: jest.fn() }; +}); + +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + __esModule: true, + useConditionalFeature: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/lib/blob', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/lib/blob'); + return { __esModule: true, ...actual, downloadUrl: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; +const useToastNotificationMock = useToastNotification as jest.Mock; +const useConditionalFeatureMock = useConditionalFeature as jest.Mock; +const displayToast = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); +const shortUrl = 'https://dly.to/devcard'; +const shareLabel = 'Share DevCard'; +const shareText = 'Check out my DevCard on daily.dev'; + +const renderComponent = ({ + isFlagOn = true, + isLaptop = true, +}: { isFlagOn?: boolean; isLaptop?: boolean } = {}): RenderResult => { + // The download button renders as an anchor on small screens; keep it a button + // so both flag states are compared on the same element. + useViewSizeMock.mockImplementation((size: ViewSize) => + size === ViewSize.Laptop ? isLaptop : false, + ); + useConditionalFeatureMock.mockImplementation( + ({ feature }: { feature: Feature }) => ({ + value: isFlagOn ? true : feature.defaultValue, + isLoading: false, + }), + ); + + const client = new QueryClient(); + // The share link is shortened through the referral campaign, so seed the + // resolved short URL instead of letting the copy path hit the network. + const { queryKey } = getShortLinkProps( + loggedUser.permalink, + ReferralCampaignKey.ShareProfile, + loggedUser, + ); + client.setQueryData(queryKey, shortUrl); + + return render( + + + , + ); +}; + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + useToastNotificationMock.mockReturnValue({ + displayToast, + dismissToast: jest.fn(), + }); + Object.assign(navigator, { clipboard: { writeText } }); + // `shouldUseNativeShare` checks for the API's presence, so it has to be + // absent rather than undefined for the copy fallback. + const shareable: { share?: unknown } = navigator; + delete shareable.share; + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); +}); + +describe('DevCardStep2 share action with the flag off', () => { + it('keeps the download-only action row', () => { + renderComponent({ isFlagOn: false }); + + expect(screen.getByText('Download DevCard')).toBeInTheDocument(); + expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument(); + }); + + it('keeps the original standalone centering on the download button', () => { + renderComponent({ isFlagOn: false }); + + const download = screen.getByText('Download DevCard').closest('button'); + expect(download).toHaveClass('mx-auto', 'mt-4', 'self-start'); + }); +}); + +describe('DevCardStep2 share action with the flag on', () => { + it('renders a labelled share control next to the download button', () => { + renderComponent(); + + expect(screen.getByText('Download DevCard')).toBeInTheDocument(); + expect(screen.getByLabelText(shareLabel)).toBeInTheDocument(); + }); + + it('keeps the download action working', async () => { + mockGraphQL({ + request: { + query: GENERATE_DEVCARD_MUTATION, + variables: { + type: 'DEFAULT', + theme: 'DEFAULT', + showBorder: true, + isProfileCover: false, + }, + }, + result: () => ({ + data: { devCard: { imageUrl: 'https://daily.dev/dc.png' } }, + }), + }); + + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByText('Download DevCard')); + }); + + await waitFor(() => + expect(downloadUrl).toHaveBeenCalledWith({ + url: 'https://daily.dev/dc.png', + filename: `${loggedUser.username}.png`, + }), + ); + }); + + it('copies the shortened profile link when native share is unavailable', async () => { + renderComponent({ isLaptop: false }); + + await act(async () => { + fireEvent.click(screen.getByLabelText(shareLabel)); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortUrl)); + expect(displayToast).toHaveBeenCalledWith( + '✅ Copied link to clipboard', + expect.anything(), + ); + }); + + it('opens the native share sheet on mobile when it is available', async () => { + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + renderComponent({ isLaptop: false }); + + await act(async () => { + fireEvent.click(screen.getByLabelText(shareLabel)); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ + text: `${shareText}\n${shortUrl}`, + }), + ); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx index f386d178e2..06c28b3a43 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx @@ -22,7 +22,13 @@ import { } from '@dailydotdev/shared/src/lib/query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { useSharingVisibility } from '@dailydotdev/shared/src/hooks/useSharingVisibility'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureShareDevcard } from '@dailydotdev/shared/src/lib/featureManagement'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import type { ShareProvider } from '@dailydotdev/shared/src/lib/share'; import { Button } from '@dailydotdev/shared/src/components/buttons/Button'; import { ClickableText } from '@dailydotdev/shared/src/components/buttons/ClickableText'; import { @@ -68,6 +74,15 @@ export const DevCardStep2 = ({ const isProfileCover = devcard?.isProfileCover ?? false; const isMobile = useViewSize(ViewSize.MobileL); const randomStr = Math.random().toString(36).substring(2, 5); + // Gate flag evaluation on the same condition the action row renders under, so + // GrowthBook is only asked when the share control could actually show. + const isDevCardReady = !isNullOrUndefined(devcard); + const { isEnabled: isSharingVisible } = useSharingVisibility(isDevCardReady); + const { value: isShareDevCardEnabled } = useConditionalFeature({ + feature: featureShareDevcard, + shouldEvaluate: isSharingVisible, + }); + const canShareDevCard = isSharingVisible && isShareDevCardEnabled; const [devCardSrc, setDevCardSrc] = useState( initialDevCardSrc ?? @@ -136,6 +151,13 @@ export const DevCardStep2 = ({ [key, client], ); + const onShareDevCard = (provider: ShareProvider) => + logEvent({ + event_name: LogEvent.ShareDevcard, + target_id: user?.id, + extra: JSON.stringify({ provider, origin: Origin.DevCard }), + }); + const onUpdateType = (newType: DevCardType) => { if (newType && newType !== type) { setType(newType); @@ -173,6 +195,25 @@ export const DevCardStep2 = ({ throw new Error('DevCard customization requires a logged-in user'); } + const downloadDevCardButton = ( + + ); + return ( <>
@@ -229,20 +270,24 @@ export const DevCardStep2 = ({
- {!isNullOrUndefined(devcard) && ( - - )} + {isDevCardReady && + (canShareDevCard ? ( +
+ {downloadDevCardButton} + +
+ ) : ( + downloadDevCardButton + ))}