From 098c1c97502f76559b507278d02fbaf44ce18686 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 19:39:05 +0300 Subject: [PATCH 01/16] feat(referral): redesign invite settings page around 3-invites Plus promo Rebuild /settings/invite as a campaign surface: a marketing-inspired hero with a cabbage-to-onion gradient headline promoting "invite 3 friends, get 1 month of Plus free", a 3-slot progress chain filled with referred users' avatars ending in a Plus reward chip, a how-it-works step grid, and a giveback cross-promo card (gated on featureGiveback) inviting users to contribute to the community. The reward framing is driven by referredUsersCount from the generic referral campaign; share link input, social share list, and the referrals list stay intact. Co-Authored-By: Claude Fable 5 --- .../webapp/__tests__/AccountInvitePage.tsx | 171 ++++++++++ packages/webapp/pages/settings/invite.tsx | 304 +++++++++++++++++- 2 files changed, 467 insertions(+), 8 deletions(-) create mode 100644 packages/webapp/__tests__/AccountInvitePage.tsx diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx new file mode 100644 index 0000000000..0a17489557 --- /dev/null +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import nock from 'nock'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql'; +import type { Visit } from '@dailydotdev/shared/src/lib/boot'; +import { + REFERRAL_CAMPAIGN_QUERY, + REFERRED_USERS_QUERY, +} from '@dailydotdev/shared/src/graphql/users'; +import type { Feature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; + +import AccountInvitePage from '../pages/settings/invite'; + +const LogContext = getLogContextStatic(); + +jest.mock('next/router', () => ({ + useRouter() { + return { + isFallback: false, + pathname: '/settings/invite', + query: {}, + push: jest.fn(), + replace: jest.fn(), + events: { on: jest.fn(), off: jest.fn() }, + }; + }, +})); + +// The page gates the giveback section on `featureGiveback`; other features in +// the tree should keep their defaults, so the mock resolves per feature. +const mockedFeatures = new Map, unknown>(); +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + useConditionalFeature: ({ feature }: { feature: Feature }) => ({ + value: mockedFeatures.has(feature) + ? mockedFeatures.get(feature) + : feature.defaultValue, + isLoading: false, + }), +})); + +let client: QueryClient; +const logEvent = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + mockedFeatures.clear(); + client = new QueryClient(); +}); + +const defaultVisit: Visit = { + sessionId: 'sample session id', + visitId: 'sample visit id', +}; + +const mockReferralCampaign = (referredUsersCount: number) => { + mockGraphQL({ + request: { + query: REFERRAL_CAMPAIGN_QUERY, + variables: { referralOrigin: 'generic' }, + }, + result: { + data: { + referralCampaign: { + referredUsersCount, + referralCountLimit: 5, + referralToken: 'token', + url: 'https://daily.dev/join?token=token', + }, + }, + }, + }); +}; + +const mockReferredUsers = () => { + mockGraphQL({ + request: { query: REFERRED_USERS_QUERY, variables: { after: '' } }, + result: { + data: { + referredUsers: { + pageInfo: { endCursor: null, hasNextPage: false }, + edges: [], + }, + }, + }, + }); +}; + +const renderComponent = (user: LoggedUser = loggedUser): RenderResult => { + mockReferredUsers(); + + return render( + + + + + + + , + ); +}; + +it('should render the 3-invites promo with the progress at zero', async () => { + mockReferralCampaign(0); + renderComponent(); + + expect(await screen.findByText('Invite 3 friends,')).toBeInTheDocument(); + expect(screen.getByText('get 1 month of Plus free')).toBeInTheDocument(); + expect(screen.getByText('Referral reward')).toBeInTheDocument(); + expect(screen.getByText('0 of 3 friends joined')).toBeInTheDocument(); +}); + +it('should reflect partial progress from the referral campaign', async () => { + mockReferralCampaign(2); + renderComponent(); + + expect(await screen.findByText('2 of 3 friends joined')).toBeInTheDocument(); + expect(screen.getByText('Referral reward')).toBeInTheDocument(); +}); + +it('should show the unlocked state once three friends joined', async () => { + mockReferralCampaign(3); + renderComponent(); + + expect(await screen.findByText('Reward unlocked')).toBeInTheDocument(); + expect( + screen.getByText('3 of 3 friends joined — enjoy your free month'), + ).toBeInTheDocument(); +}); + +it('should link to the giveback page when the feature is enabled', async () => { + mockedFeatures.set(featureGiveback, true); + mockReferralCampaign(0); + renderComponent(); + + expect(await screen.findByText('More ways to give back')).toBeInTheDocument(); + const cta = screen.getByText('Explore Giveback →').closest('a'); + expect(cta).toHaveAttribute('href', expect.stringContaining('giveback')); +}); + +it('should hide the giveback section when the feature is disabled', async () => { + mockedFeatures.set(featureGiveback, false); + mockReferralCampaign(0); + renderComponent(); + + await waitFor(() => { + expect( + screen.queryByText('More ways to give back'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 2f2a11ac0c..d419458682 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -1,12 +1,16 @@ import type { ReactElement } from 'react'; -import React, { useMemo, useRef } from 'react'; +import React, { Fragment, useMemo, useRef } from 'react'; +import classNames from 'classnames'; import { ReferralCampaignKey, useReferralCampaign, } from '@dailydotdev/shared/src/hooks'; import { link } from '@dailydotdev/shared/src/lib/links'; import { labels } from '@dailydotdev/shared/src/lib'; -import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image'; +import { + cloudinaryCharmGiveback, + cloudinaryCharmInviteFriends, +} from '@dailydotdev/shared/src/lib/image'; import { Image } from '@dailydotdev/shared/src/components/image/Image'; import { generateQueryKey, @@ -41,6 +45,27 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + AddUserIcon, + CopyIcon, + DevPlusIcon, + VIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { usePlusSubscription } from '@dailydotdev/shared/src/hooks/usePlusSubscription'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import AccountContentSection from '../../components/layouts/SettingsLayout/AccountContentSection'; import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; @@ -53,8 +78,158 @@ const seo: NextSeoProps = { ...noindexSeoProps, }; +const INVITE_GOAL = 3; + +const HOW_IT_WORKS_STEPS = [ + { + title: 'Share your link', + description: + 'Send your personal invite link to developers who would appreciate signal over noise.', + }, + { + title: 'They join daily.dev', + description: + 'Every developer who signs up through your link shows up in your referrals below.', + }, + { + title: 'Plus unlocks', + description: + 'Hit three joins and we upgrade you to Plus for a full month, on us.', + }, +] as const; + +interface InviteProgressProps { + joinedCount: number; + isCompleted: boolean; + referredUsers: UserShortProfile[]; +} + +const InviteProgress = ({ + joinedCount, + isCompleted, + referredUsers, +}: InviteProgressProps): ReactElement => ( +
+ {Array.from({ length: INVITE_GOAL }, (_, index) => { + const isFilled = index < joinedCount; + const referredUser = referredUsers[index]; + + return ( + + {index > 0 && ( + + )} + {isFilled && referredUser ? ( + + ) : ( + + {isFilled ? : } + + )} + + ); + })} + + + 1 month + +
+); + +const GivebackPromoCard = ({ + onClick, +}: { + onClick: () => void; +}): ReactElement => ( +
+
+ + Raised together + + + Turn community actions into real-world donations + + + Developers spread the word about daily.dev, and we redirect our growth + budget to causes the community picks. You never pay a cent. + + +
+ {/* The charm artwork sits on black; screen-blend drops the black onto the card. */} +
+ + daily.dev Giveback charm +
+
+); + const AccountInvitePage = (): ReactElement => { const { user } = useAuthContext(); + const { isPlus } = usePlusSubscription(); const container = useRef(); const referredKey = generateQueryKey(RequestKey.ReferredUsers, user); const { url, referredUsersCount } = useReferralCampaign({ @@ -62,7 +237,7 @@ const AccountInvitePage = (): ReactElement => { }); const { logEvent } = useLogContext(); const inviteLink = url || link.referral.defaultUrl; - const [, onShareOrCopyLink] = useShareOrCopyLink({ + const [isCopying, onShareOrCopyLink] = useShareOrCopyLink({ text: labels.referral.generic.inviteText, link: inviteLink, logObject: () => ({ @@ -70,6 +245,10 @@ const AccountInvitePage = (): ReactElement => { target_id: TargetId.InviteFriendsPage, }), }); + const { value: isGivebackEnabled } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate: !!user, + }); const usersResult = useInfiniteQuery({ queryKey: referredKey, queryFn: ({ pageParam }) => @@ -91,6 +270,9 @@ const AccountInvitePage = (): ReactElement => { return list; }, [usersResult]); + const joinedCount = Math.min(referredUsersCount, INVITE_GOAL); + const isCompleted = referredUsersCount >= INVITE_GOAL; + const onLogShare = (provider: ShareProvider) => { logEvent({ event_name: LogEvent.InviteReferral, @@ -99,13 +281,82 @@ const AccountInvitePage = (): ReactElement => { }); }; + const onGivebackClick = () => { + logEvent({ + event_name: LogEvent.ClickGivebackGiftEntry, + target_id: TargetId.InviteFriendsPage, + }); + }; + return ( - +
+ {/* Aurora glows echoing the marketing landing hero. */} +
+ + +
+
+ + + {isCompleted ? 'Reward unlocked' : 'Referral reward'} + + + Invite 3 friends, + + get 1 month of Plus free + + + + {isCompleted + ? 'Three friends in — your free month of Plus is unlocked. Your invites still count, and every developer you bring makes the feed sharper for everyone.' + : 'Know developers still digging through noise for good content? Send them your link. When three of them join daily.dev, your next month of Plus is on us.'} + + {isPlus && !isCompleted && ( + + Already on Plus? Your free month gets added on top of your current + subscription. + + )} + + + {isCompleted + ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — enjoy your free month` + : `${joinedCount} of ${INVITE_GOAL} friends joined`} + + +
+
{ /> + +
+ {HOW_IT_WORKS_STEPS.map((step, index) => ( +
+ + {index + 1} + + + {step.title} + + + {step.description} + +
+ ))} +
+
+ {isGivebackEnabled && ( + + + + )} Date: Wed, 22 Jul 2026 23:29:49 +0300 Subject: [PATCH 02/16] refactor(referral): flatten invite page into compact, consistent sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the aurora hero and per-section bespoke styling with one visual language: every block is a standard settings AccountContentSection, the progress chain shrinks to a compact inline row (size-8 avatar slots + neutral connectors + a single Plus-accent chip), how-it-works becomes a flat numbered list, and the giveback promo becomes a plain float card with a gift icon instead of gradients and mascot art. No gradients, glows, blend modes, or animations — one accent color, one card treatment, one section rhythm. Co-Authored-By: Claude Fable 5 --- .../webapp/__tests__/AccountInvitePage.tsx | 15 +- packages/webapp/pages/settings/invite.tsx | 326 +++++++----------- 2 files changed, 124 insertions(+), 217 deletions(-) diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx index 0a17489557..e8b2a5762e 100644 --- a/packages/webapp/__tests__/AccountInvitePage.tsx +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -124,9 +124,9 @@ it('should render the 3-invites promo with the progress at zero', async () => { mockReferralCampaign(0); renderComponent(); - expect(await screen.findByText('Invite 3 friends,')).toBeInTheDocument(); - expect(screen.getByText('get 1 month of Plus free')).toBeInTheDocument(); - expect(screen.getByText('Referral reward')).toBeInTheDocument(); + expect( + await screen.findByText('Invite 3 friends, get 1 month of Plus free'), + ).toBeInTheDocument(); expect(screen.getByText('0 of 3 friends joined')).toBeInTheDocument(); }); @@ -135,16 +135,17 @@ it('should reflect partial progress from the referral campaign', async () => { renderComponent(); expect(await screen.findByText('2 of 3 friends joined')).toBeInTheDocument(); - expect(screen.getByText('Referral reward')).toBeInTheDocument(); }); it('should show the unlocked state once three friends joined', async () => { mockReferralCampaign(3); renderComponent(); - expect(await screen.findByText('Reward unlocked')).toBeInTheDocument(); expect( - screen.getByText('3 of 3 friends joined — enjoy your free month'), + await screen.findByText('3 of 3 friends joined — free month unlocked'), + ).toBeInTheDocument(); + expect( + screen.getByText(/your free month of Plus is unlocked/), ).toBeInTheDocument(); }); @@ -154,7 +155,7 @@ it('should link to the giveback page when the feature is enabled', async () => { renderComponent(); expect(await screen.findByText('More ways to give back')).toBeInTheDocument(); - const cta = screen.getByText('Explore Giveback →').closest('a'); + const cta = screen.getByText('Explore Giveback').closest('a'); expect(cta).toHaveAttribute('href', expect.stringContaining('giveback')); }); diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index d419458682..1a10db14fe 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -7,10 +7,7 @@ import { } from '@dailydotdev/shared/src/hooks'; import { link } from '@dailydotdev/shared/src/lib/links'; import { labels } from '@dailydotdev/shared/src/lib'; -import { - cloudinaryCharmGiveback, - cloudinaryCharmInviteFriends, -} from '@dailydotdev/shared/src/lib/image'; +import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image'; import { Image } from '@dailydotdev/shared/src/components/image/Image'; import { generateQueryKey, @@ -47,14 +44,13 @@ import { import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { Button, - ButtonColor, ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { AddUserIcon, - CopyIcon, DevPlusIcon, + GiftIcon, VIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; @@ -83,18 +79,16 @@ const INVITE_GOAL = 3; const HOW_IT_WORKS_STEPS = [ { title: 'Share your link', - description: - 'Send your personal invite link to developers who would appreciate signal over noise.', + description: 'Copy it or send it through any of the platforms above.', }, { title: 'They join daily.dev', description: - 'Every developer who signs up through your link shows up in your referrals below.', + 'Each signup through your link appears in your referrals below.', }, { title: 'Plus unlocks', - description: - 'Hit three joins and we upgrade you to Plus for a full month, on us.', + description: 'After three joins, your free month of Plus kicks in.', }, ] as const; @@ -109,121 +103,59 @@ const InviteProgress = ({ isCompleted, referredUsers, }: InviteProgressProps): ReactElement => ( -
- {Array.from({ length: INVITE_GOAL }, (_, index) => { - const isFilled = index < joinedCount; - const referredUser = referredUsers[index]; - - return ( - - {index > 0 && ( - - )} - {isFilled && referredUser ? ( - - ) : ( - - {isFilled ? : } - - )} - - ); - })} - - +
- 1 month - -
-); + {Array.from({ length: INVITE_GOAL }, (_, index) => { + const isFilled = index < joinedCount; + const referredUser = referredUsers[index]; -const GivebackPromoCard = ({ - onClick, -}: { - onClick: () => void; -}): ReactElement => ( -
-
- - Raised together - - - Turn community actions into real-world donations - - - Developers spread the word about daily.dev, and we redirect our growth - budget to causes the community picks. You never pay a cent. - - -
- {/* The charm artwork sits on black; screen-blend drops the black onto the card. */} -
+ return ( + + {index > 0 && ( + + )} + {isFilled && referredUser ? ( + + ) : ( + + {isFilled ? : } + + )} + + ); + })} + - daily.dev Giveback charm + className={classNames( + 'flex items-center gap-0.5 rounded-full bg-action-plus-float px-2 py-0.5 font-bold text-action-plus-default typo-footnote', + isCompleted && 'border border-action-plus-default', + )} + > + 1 month +
+ + {isCompleted + ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — free month unlocked` + : `${joinedCount} of ${INVITE_GOAL} friends joined`} +
); @@ -237,7 +169,7 @@ const AccountInvitePage = (): ReactElement => { }); const { logEvent } = useLogContext(); const inviteLink = url || link.referral.defaultUrl; - const [isCopying, onShareOrCopyLink] = useShareOrCopyLink({ + const [, onShareOrCopyLink] = useShareOrCopyLink({ text: labels.referral.generic.inviteText, link: inviteLink, logObject: () => ({ @@ -290,77 +222,30 @@ const AccountInvitePage = (): ReactElement => { return ( -
- {/* Aurora glows echoing the marketing landing hero. */} -
- - -
-
- - - {isCompleted ? 'Reward unlocked' : 'Referral reward'} - - - Invite 3 friends, - - get 1 month of Plus free - - - - {isCompleted - ? 'Three friends in — your free month of Plus is unlocked. Your invites still count, and every developer you bring makes the feed sharper for everyone.' - : 'Know developers still digging through noise for good content? Send them your link. When three of them join daily.dev, your next month of Plus is on us.'} - - {isPlus && !isCompleted && ( - - Already on Plus? Your free month gets added on top of your current - subscription. - - )} - + + {isPlus && !isCompleted && ( - {isCompleted - ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — enjoy your free month` - : `${joinedCount} of ${INVITE_GOAL} friends joined`} + Already on Plus? Your free month gets added on top of your current + subscription. - -
-
- + )} + { />
- -
+ +
    {HOW_IT_WORKS_STEPS.map((step, index) => ( -
    +
  1. {index + 1} +
    + + {step.title} + + + {step.description} + +
    +
  2. + ))} +
+
+ {isGivebackEnabled && ( + +
+ + + +
- {step.title} + Turn community actions into real donations - {step.description} + We redirect our growth budget to causes the community picks — + you never pay a cent.
- ))} -
-
- {isGivebackEnabled && ( - - + +
)} Date: Wed, 22 Jul 2026 23:38:48 +0300 Subject: [PATCH 03/16] style(referral): match giveback card to the founding-reward treatment Reuse the visual language of GivebackFoundingAward for the invite page's giveback promo: a 1px avocado/cabbage/cheese gradient frame over an opaque base washed with the same gradient at 8% via color-mix, a gradient-text eyebrow, callout title, footnote body, primary CTA, and the Giveback charm on a glow. Co-Authored-By: Claude Fable 5 --- packages/webapp/pages/settings/invite.tsx | 81 +++++++++++++++-------- 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 1a10db14fe..09645e15ce 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -7,7 +7,10 @@ import { } from '@dailydotdev/shared/src/hooks'; import { link } from '@dailydotdev/shared/src/lib/links'; import { labels } from '@dailydotdev/shared/src/lib'; -import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image'; +import { + cloudinaryCharmGiveback, + cloudinaryCharmInviteFriends, +} from '@dailydotdev/shared/src/lib/image'; import { Image } from '@dailydotdev/shared/src/components/image/Image'; import { generateQueryKey, @@ -50,7 +53,6 @@ import { import { AddUserIcon, DevPlusIcon, - GiftIcon, VIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; @@ -303,31 +305,58 @@ const AccountInvitePage = (): ReactElement => { title="More ways to give back" description="Inviting friends isn't the only way to push the community forward." > -
- - - -
- - Turn community actions into real donations - - - We redirect our growth budget to causes the community picks — - you never pay a cent. - -
- +
+ + Community giveback + + + Turn community actions into real donations + + + We redirect our growth budget to causes the community picks — + you never pay a cent. + + +
+ {/* The charm artwork sits on black; screen-blend drops the black + on the dark card. */} + + + daily.dev Giveback charm + +
)} From 479dbc0dcc51443d56872cd87e2bf9c5ea61afb7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 08:54:10 +0300 Subject: [PATCH 04/16] refactor(referral): simplify invite copy and progress, drop how-it-works Tighten the offer copy to one short line, name Plus in the reward chip, and switch the chip and empty invite slots from pills/circles to rounded-10 rectangles so they match the ProfilePicture shape. The progress caption moves above the slot row, the invite link and social buttons get their own 'Your invitation link' section, and the how-it-works list is removed. Co-Authored-By: Claude Fable 5 --- .../webapp/__tests__/AccountInvitePage.tsx | 7 +- packages/webapp/pages/settings/invite.tsx | 118 ++++++------------ 2 files changed, 41 insertions(+), 84 deletions(-) diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx index e8b2a5762e..52aa3da4b3 100644 --- a/packages/webapp/__tests__/AccountInvitePage.tsx +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -125,9 +125,10 @@ it('should render the 3-invites promo with the progress at zero', async () => { renderComponent(); expect( - await screen.findByText('Invite 3 friends, get 1 month of Plus free'), + await screen.findByText('Invite 3 friends, get 1 month of Plus'), ).toBeInTheDocument(); expect(screen.getByText('0 of 3 friends joined')).toBeInTheDocument(); + expect(screen.getByText('Your invitation link')).toBeInTheDocument(); }); it('should reflect partial progress from the referral campaign', async () => { @@ -142,10 +143,10 @@ it('should show the unlocked state once three friends joined', async () => { renderComponent(); expect( - await screen.findByText('3 of 3 friends joined — free month unlocked'), + await screen.findByText('3 of 3 friends joined — Plus unlocked'), ).toBeInTheDocument(); expect( - screen.getByText(/your free month of Plus is unlocked/), + screen.getByText('3 friends joined. Your free month of Plus is unlocked.'), ).toBeInTheDocument(); }); diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 09645e15ce..ba481f03e5 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { Fragment, useMemo, useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import classNames from 'classnames'; import { ReferralCampaignKey, @@ -78,22 +78,6 @@ const seo: NextSeoProps = { const INVITE_GOAL = 3; -const HOW_IT_WORKS_STEPS = [ - { - title: 'Share your link', - description: 'Copy it or send it through any of the platforms above.', - }, - { - title: 'They join daily.dev', - description: - 'Each signup through your link appears in your referrals below.', - }, - { - title: 'Plus unlocks', - description: 'After three joins, your free month of Plus kicks in.', - }, -] as const; - interface InviteProgressProps { joinedCount: number; isCompleted: boolean; @@ -105,59 +89,55 @@ const InviteProgress = ({ isCompleted, referredUsers, }: InviteProgressProps): ReactElement => ( -
+
+ + {isCompleted + ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — Plus unlocked` + : `${joinedCount} of ${INVITE_GOAL} friends joined`} +
{Array.from({ length: INVITE_GOAL }, (_, index) => { const isFilled = index < joinedCount; const referredUser = referredUsers[index]; + if (isFilled && referredUser) { + return ( + + ); + } + return ( - - {index > 0 && ( - - )} - {isFilled && referredUser ? ( - - ) : ( - - {isFilled ? : } - + + > + {isFilled ? : } + ); })} - 1 month + 1 month of Plus
- - {isCompleted - ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — free month unlocked` - : `${joinedCount} of ${INVITE_GOAL} friends joined`} -
); @@ -226,11 +206,11 @@ const AccountInvitePage = (): ReactElement => { {isPlus && !isCompleted && ( @@ -239,8 +219,7 @@ const AccountInvitePage = (): ReactElement => { type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - Already on Plus? Your free month gets added on top of your current - subscription. + Already on Plus? The free month is added to your subscription. )} { isCompleted={isCompleted} referredUsers={users} /> + + { />
- -
    - {HOW_IT_WORKS_STEPS.map((step, index) => ( -
  1. - - {index + 1} - -
    - - {step.title} - - - {step.description} - -
    -
  2. - ))} -
-
{isGivebackEnabled && ( Date: Thu, 23 Jul 2026 09:03:37 +0300 Subject: [PATCH 05/16] fix(referral): left-align the referrals list with the rest of the page UserList hard-codes px-6 on each row and overrides the consumer's className, so referral rows sat 24px right of every heading. Cancel the section padding on the list wrapper so rows line up and the hover highlight spans the card, drop the dead className config, and make the empty state a compact left-aligned row instead of a centered block. Co-Authored-By: Claude Fable 5 --- packages/webapp/pages/settings/invite.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index ba481f03e5..8f8df65593 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -327,27 +327,26 @@ const AccountInvitePage = (): ReactElement => { isFetchingNextPage: usersResult.isFetchingNextPage, canFetchMore: checkFetchMore(usersResult), fetchNextPage: usersResult.fetchNextPage, - className: 'mt-4', + // UserList hard-codes px-6 on every row; cancel the section's own + // p-6 so the rows line up with the headings above them and the + // hover highlight runs the full width of the card. + className: 'mt-4 -mx-6', }} emptyPlaceholder={ -
+
daily.dev charm inviting your friends -

+

No one has joined yet. Share your link!

} userInfoProps={{ scrollingContainer: container.current, - className: { - container: 'px-0 py-3 items-center', - textWrapper: 'flex-none', - }, transformUsername({ username, createdAt, From 6a013c247fce93f44756280f0508325cd11da29b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 09:26:43 +0300 Subject: [PATCH 06/16] feat(referral): add invite friends Storybook page with all states Move the two bespoke pieces of the invite page - the reward progress tracker and the giveback cross-promo card - into shared/src/features/referral so Storybook can render the real components (the page body itself lives in webapp and isn't importable there). Add Features/Referral/Invite friends page covering 0/1/2/3+ referrals, the unlocked reward, the existing-Plus-member note, the giveback flag off, mobile width, and component-level showcases. Co-Authored-By: Claude Fable 5 --- .../components/GivebackInviteCard.tsx | 85 +++++ .../components/InviteRewardProgress.tsx | 89 +++++ .../referral/InviteFriends.stories.tsx | 325 ++++++++++++++++++ .../features/referral/inviteFriends.mocks.tsx | 123 +++++++ packages/webapp/pages/settings/invite.tsx | 150 +------- 5 files changed, 631 insertions(+), 141 deletions(-) create mode 100644 packages/shared/src/features/referral/components/GivebackInviteCard.tsx create mode 100644 packages/shared/src/features/referral/components/InviteRewardProgress.tsx create mode 100644 packages/storybook/stories/features/referral/InviteFriends.stories.tsx create mode 100644 packages/storybook/stories/features/referral/inviteFriends.mocks.tsx diff --git a/packages/shared/src/features/referral/components/GivebackInviteCard.tsx b/packages/shared/src/features/referral/components/GivebackInviteCard.tsx new file mode 100644 index 0000000000..c764770ce9 --- /dev/null +++ b/packages/shared/src/features/referral/components/GivebackInviteCard.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { cloudinaryCharmGiveback } from '../../../lib/image'; +import { webappUrl } from '../../../lib/constants'; + +interface GivebackInviteCardProps { + onClick?: () => void; + className?: string; +} + +// Cross-promo into /giveback, wearing the same treatment as the giveback +// founding-reward card: a 1px gradient frame over an opaque base washed with +// the same gradient at a true 8% via color-mix (Tailwind's `/opacity` +// modifier doesn't take on these token gradient stops). +export const GivebackInviteCard = ({ + onClick, + className, +}: GivebackInviteCardProps): ReactElement => ( +
+
+
+ + Community giveback + + + Turn community actions into real donations + + + We redirect our growth budget to causes the community picks — you + never pay a cent. + + +
+ {/* The charm artwork sits on black; screen-blend drops the black on the + dark card. */} + + + daily.dev Giveback charm + +
+
+); diff --git a/packages/shared/src/features/referral/components/InviteRewardProgress.tsx b/packages/shared/src/features/referral/components/InviteRewardProgress.tsx new file mode 100644 index 0000000000..a0a13c2577 --- /dev/null +++ b/packages/shared/src/features/referral/components/InviteRewardProgress.tsx @@ -0,0 +1,89 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../../components/ProfilePicture'; +import { AddUserIcon, DevPlusIcon, VIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import type { UserShortProfile } from '../../../lib/user'; + +export const INVITE_GOAL = 3; + +interface InviteRewardProgressProps { + joinedCount: number; + referredUsers: UserShortProfile[]; + className?: string; +} + +// The referral reward tracker: one slot per friend needed, filled with the +// avatars of the developers who actually joined, ending in the Plus reward. +// Slots use the same rounded-rect radius as ProfilePicture so filled and empty +// slots share a silhouette. +export const InviteRewardProgress = ({ + joinedCount, + referredUsers, + className, +}: InviteRewardProgressProps): ReactElement => { + const isCompleted = joinedCount >= INVITE_GOAL; + const caption = isCompleted + ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — Plus unlocked` + : `${Math.max(0, joinedCount)} of ${INVITE_GOAL} friends joined`; + + return ( +
+ + {caption} + +
+ {Array.from({ length: INVITE_GOAL }, (_, index) => { + const isFilled = index < joinedCount; + const referredUser = referredUsers[index]; + + if (isFilled && referredUser) { + return ( + + ); + } + + return ( + + {isFilled ? : } + + ); + })} + + + 1 month of Plus + +
+
+ ); +}; diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx new file mode 100644 index 0000000000..1bb179ffde --- /dev/null +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -0,0 +1,325 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { format } from 'date-fns'; +import { + INVITE_GOAL, + InviteRewardProgress, +} from '@dailydotdev/shared/src/features/referral/components/InviteRewardProgress'; +import { GivebackInviteCard } from '@dailydotdev/shared/src/features/referral/components/GivebackInviteCard'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; +import UserList from '@dailydotdev/shared/src/components/profile/UserList'; +import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; +import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import { + INVITE_LINK, + mockReferredUsers, + withInvite, +} from './inviteFriends.mocks'; + +const noop = (): void => undefined; + +// Stand-ins for the webapp settings chrome (AccountPageContainer / +// AccountContentSection), so the real components can be judged in the layout +// they actually ship in. The page body itself lives in the webapp package and +// can't be imported here. +const SettingsCard = ({ children }: { children: ReactNode }): ReactElement => ( +
+
+

+ Invite friends +

+
+ {children} +
+
+
+); + +const Section = ({ + title, + description, + isFirst = false, + children, +}: { + title: string; + description?: string; + isFirst?: boolean; + children?: ReactNode; +}): ReactElement => ( + <> +

+ {title} +

+ {description && ( +

{description}

+ )} + {children} + +); + +interface InvitePageProps { + joinedCount?: number; + isPlus?: boolean; + showGiveback?: boolean; +} + +// Mirrors packages/webapp/pages/settings/invite.tsx section for section. +const InvitePage = ({ + joinedCount = 0, + isPlus = false, + showGiveback = true, +}: InvitePageProps): ReactElement => { + const referredUsers = mockReferredUsers().slice(0, joinedCount); + const isCompleted = joinedCount >= INVITE_GOAL; + + return ( + +
+ {isPlus && !isCompleted && ( + + Already on Plus? The free month is added to your subscription. + + )} + +
+
+ + + or invite via + +
+ +
+
+ {showGiveback && ( +
+ +
+ )} +
+ Promise.resolve(), + className: 'mt-4 -mx-6', + }} + emptyPlaceholder={ +
+ daily.dev charm inviting your friends +

+ No one has joined yet. Share your link! +

+
+ } + userInfoProps={{ + transformUsername({ + username, + createdAt, + }: UserShortProfile): ReactNode { + return ( + + @{username} + + + + ); + }, + }} + /> +
+
+ ); +}; + +const meta: Meta = { + title: 'Features/Referral/Invite friends page', + component: InvitePage, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Every state of the "Invite friends" settings page (/settings/invite): referral progress from zero to unlocked, the Plus-member variant, and the giveback cross-promo on and off. The reward chip is framing only — no backend grants Plus at 3 referrals yet.', + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const NoInvitesYet: Story = { + name: '0 of 3 — nobody joined', + args: { joinedCount: 0 }, + decorators: [withInvite()], +}; + +export const OneFriendJoined: Story = { + name: '1 of 3 — first join', + args: { joinedCount: 1 }, + decorators: [withInvite()], +}; + +export const TwoFriendsJoined: Story = { + name: '2 of 3 — one to go', + args: { joinedCount: 2 }, + decorators: [withInvite()], +}; + +export const RewardUnlocked: Story = { + name: '3 of 3 — Plus unlocked', + args: { joinedCount: 3 }, + decorators: [withInvite()], +}; + +export const BeyondTheGoal: Story = { + name: '4 joined — past the goal', + args: { joinedCount: 4 }, + decorators: [withInvite()], + parameters: { + docs: { + description: { + story: + 'Progress caps at three slots while the referrals list keeps growing.', + }, + }, + }, +}; + +export const ExistingPlusMember: Story = { + name: 'Already a Plus member', + args: { joinedCount: 1, isPlus: true }, + decorators: [withInvite({ isPlus: true })], + parameters: { + docs: { + description: { + story: + 'Plus subscribers get a note that the free month stacks on their current subscription. It disappears once the reward unlocks.', + }, + }, + }, +}; + +export const GivebackDisabled: Story = { + name: 'Giveback flag off', + args: { joinedCount: 1, showGiveback: false }, + decorators: [withInvite()], +}; + +export const Mobile: Story = { + name: 'Mobile width', + args: { joinedCount: 2 }, + decorators: [withInvite()], + globals: { viewport: { value: 'mobile1' } }, +}; + +// Component-level states, side by side, for judging the tracker on its own. +export const ProgressStates: StoryObj = { + name: 'Reward progress — all steps', + render: () => ( +
+ {[0, 1, 2, 3].map((count) => ( +
+ + joinedCount = {count} + + +
+ ))} +
+ ), + decorators: [withInvite()], + parameters: { controls: { disable: true } }, +}; + +export const GivebackCard: StoryObj = { + name: 'Giveback cross-promo card', + render: () => ( +
+ +
+ ), + decorators: [withInvite()], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Wears the same treatment as the giveback founding-reward card: a 1px gradient frame over an opaque base washed with the same gradient at 8%.', + }, + }, + }, +}; diff --git a/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx new file mode 100644 index 0000000000..50cf443f1f --- /dev/null +++ b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx @@ -0,0 +1,123 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import type { Decorator } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; + +const noop = (): void => undefined; + +export const MOCK_USER = { + id: 'sb-user', + name: 'Dev Dana', + username: 'devdana', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/devdana', + bio: null, + createdAt: '2021-01-01T00:00:00.000Z', + reputation: 42, + providers: ['github'], +} as const; + +export const INVITE_LINK = 'https://api.daily.dev/get?r=devdana'; + +// The developers who joined through the link. Stories slice this list to get a +// given progress state, so slot N is always the same person. +export const mockReferredUsers = (): UserShortProfile[] => + [ + { + id: 'ref-1', + name: 'Ida Kern', + username: 'idakern', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/idakern', + createdAt: '2026-05-04T10:00:00.000Z', + reputation: 120, + }, + { + id: 'ref-2', + name: 'Ravi Menon', + username: 'ravimenon', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/ravimenon', + createdAt: '2026-06-11T10:00:00.000Z', + reputation: 340, + }, + { + id: 'ref-3', + name: 'Wren Halloway', + username: 'wrenh', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/wrenh', + createdAt: '2026-07-02T10:00:00.000Z', + reputation: 88, + }, + { + id: 'ref-4', + name: 'Søren Bakke', + username: 'sorenb', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/sorenb', + createdAt: '2026-07-19T10:00:00.000Z', + reputation: 15, + }, + ] as UserShortProfile[]; + +const InviteProviders = ({ + isPlus = false, + children, +}: { + isPlus?: boolean; + children: ReactNode; +}): ReactElement => { + const queryClient = useMemo(() => new QueryClient(), []); + const LogContext = getLogContextStatic(); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + + {children} + + + + ); +}; + +export const withInvite = + ({ isPlus }: { isPlus?: boolean } = {}): Decorator => + (Story) => + ( + + + + ); diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 8f8df65593..6dae822897 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -1,16 +1,12 @@ import type { ReactElement } from 'react'; import React, { useMemo, useRef } from 'react'; -import classNames from 'classnames'; import { ReferralCampaignKey, useReferralCampaign, } from '@dailydotdev/shared/src/hooks'; import { link } from '@dailydotdev/shared/src/lib/links'; import { labels } from '@dailydotdev/shared/src/lib'; -import { - cloudinaryCharmGiveback, - cloudinaryCharmInviteFriends, -} from '@dailydotdev/shared/src/lib/image'; +import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image'; import { Image } from '@dailydotdev/shared/src/components/image/Image'; import { generateQueryKey, @@ -45,25 +41,14 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - AddUserIcon, - DevPlusIcon, - VIcon, -} from '@dailydotdev/shared/src/components/icons'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { - ProfileImageSize, - ProfilePicture, -} from '@dailydotdev/shared/src/components/ProfilePicture'; import { usePlusSubscription } from '@dailydotdev/shared/src/hooks/usePlusSubscription'; import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; -import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { + INVITE_GOAL, + InviteRewardProgress, +} from '@dailydotdev/shared/src/features/referral/components/InviteRewardProgress'; +import { GivebackInviteCard } from '@dailydotdev/shared/src/features/referral/components/GivebackInviteCard'; import AccountContentSection from '../../components/layouts/SettingsLayout/AccountContentSection'; import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; @@ -76,71 +61,6 @@ const seo: NextSeoProps = { ...noindexSeoProps, }; -const INVITE_GOAL = 3; - -interface InviteProgressProps { - joinedCount: number; - isCompleted: boolean; - referredUsers: UserShortProfile[]; -} - -const InviteProgress = ({ - joinedCount, - isCompleted, - referredUsers, -}: InviteProgressProps): ReactElement => ( -
- - {isCompleted - ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — Plus unlocked` - : `${joinedCount} of ${INVITE_GOAL} friends joined`} - -
- {Array.from({ length: INVITE_GOAL }, (_, index) => { - const isFilled = index < joinedCount; - const referredUser = referredUsers[index]; - - if (isFilled && referredUser) { - return ( - - ); - } - - return ( - - {isFilled ? : } - - ); - })} - - - 1 month of Plus - -
-
-); - const AccountInvitePage = (): ReactElement => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); @@ -222,9 +142,9 @@ const AccountInvitePage = (): ReactElement => { Already on Plus? The free month is added to your subscription. )} - @@ -261,59 +181,7 @@ const AccountInvitePage = (): ReactElement => { title="More ways to give back" description="Inviting friends isn't the only way to push the community forward." > - {/* Same treatment as the giveback founding-reward card: a 1px - gradient frame over an opaque base, washed with the same gradient - at a true 8% via color-mix. */} -
-
-
- - Community giveback - - - Turn community actions into real donations - - - We redirect our growth budget to causes the community picks — - you never pay a cent. - - -
- {/* The charm artwork sits on black; screen-blend drops the black - on the dark card. */} - - - daily.dev Giveback charm - -
-
+ )} Date: Thu, 23 Jul 2026 11:32:03 +0300 Subject: [PATCH 07/16] refactor(referral): gate the Plus reward behind a flag and clean up Engineering pass over the invite page redesign: - Gate the "invite 3, get 1 month of Plus" promo behind a new referral_plus_reward flag (default off). Nothing in the product grants the free month yet, so with the flag off the page describes referrals and promises nothing. - Fix the unlocked copy claiming "3 friends joined" for any count at or above the goal; it is now count-agnostic, and the tracker clamps 0..INVITE_GOAL internally. - Move the two new components to their conventional homes (components/referral, features/giveback) and delete the single-export components/referral barrel per the repo's no-barrel rule. - Wrap the whole referrals list in the negative margin instead of passing it through scrollingProps, which only reached the loaded branch and let skeleton rows sit 24px off before snapping into place. - Let the progress row wrap and drop its connector below mobileL, where the reward chip was clipped at 320px. - Drop the never-attached scrollingContainer ref, the redundant clamp, an aria-label duplicating visible text, decorative alt text, and a border that resized the chip on unlock. - Storybook: match the real settings chrome, add the flag-off story, and remove an inert isPlus decorator option. - Tests: assert the exact giveback href and click logging, cover the flag-off and past-the-goal states, and exercise the avatar path. Co-Authored-By: Claude Fable 5 --- .../referral}/InviteRewardProgress.tsx | 40 +++-- .../shared/src/components/referral/index.ts | 1 - .../components/GivebackInviteCard.tsx | 23 +-- packages/shared/src/lib/featureManagement.ts | 8 + .../referral/InviteFriends.stories.tsx | 145 ++++++++++------- .../features/referral/inviteFriends.mocks.tsx | 10 +- .../webapp/__tests__/AccountInvitePage.tsx | 84 ++++++++-- packages/webapp/pages/settings/invite.tsx | 147 ++++++++++-------- .../pages/settings/organization/index.tsx | 2 +- 9 files changed, 297 insertions(+), 163 deletions(-) rename packages/shared/src/{features/referral/components => components/referral}/InviteRewardProgress.tsx (58%) delete mode 100644 packages/shared/src/components/referral/index.ts rename packages/shared/src/features/{referral => giveback}/components/GivebackInviteCard.tsx (73%) diff --git a/packages/shared/src/features/referral/components/InviteRewardProgress.tsx b/packages/shared/src/components/referral/InviteRewardProgress.tsx similarity index 58% rename from packages/shared/src/features/referral/components/InviteRewardProgress.tsx rename to packages/shared/src/components/referral/InviteRewardProgress.tsx index a0a13c2577..e00734ce2b 100644 --- a/packages/shared/src/features/referral/components/InviteRewardProgress.tsx +++ b/packages/shared/src/components/referral/InviteRewardProgress.tsx @@ -5,18 +5,17 @@ import { Typography, TypographyColor, TypographyType, -} from '../../../components/typography/Typography'; -import { - ProfileImageSize, - ProfilePicture, -} from '../../../components/ProfilePicture'; -import { AddUserIcon, DevPlusIcon, VIcon } from '../../../components/icons'; -import { IconSize } from '../../../components/Icon'; -import type { UserShortProfile } from '../../../lib/user'; +} from '../typography/Typography'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import { AddUserIcon, DevPlusIcon, VIcon } from '../icons'; +import { IconSize } from '../Icon'; +import type { UserShortProfile } from '../../lib/user'; export const INVITE_GOAL = 3; interface InviteRewardProgressProps { + // The raw number of developers who joined. Values outside 0..INVITE_GOAL are + // clamped here, so callers can pass the referral count as-is. joinedCount: number; referredUsers: UserShortProfile[]; className?: string; @@ -31,10 +30,11 @@ export const InviteRewardProgress = ({ referredUsers, className, }: InviteRewardProgressProps): ReactElement => { - const isCompleted = joinedCount >= INVITE_GOAL; + const joined = Math.min(Math.max(0, joinedCount), INVITE_GOAL); + const isCompleted = joined >= INVITE_GOAL; const caption = isCompleted ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — Plus unlocked` - : `${Math.max(0, joinedCount)} of ${INVITE_GOAL} friends joined`; + : `${joined} of ${INVITE_GOAL} friends joined`; return (
@@ -44,15 +44,18 @@ export const InviteRewardProgress = ({ > {caption} -
+ {/* The caption above already states the progress, and every referred + developer is named in the list further down the page, so the slot row + is presentational. */} +
{Array.from({ length: INVITE_GOAL }, (_, index) => { - const isFilled = index < joinedCount; + const isFilled = index < joined; const referredUser = referredUsers[index]; if (isFilled && referredUser) { return ( @@ -61,7 +64,6 @@ export const InviteRewardProgress = ({ return ( ); })} - + {/* The connector is the first thing to go on narrow phones, where the + slots and the reward chip only just fit on one line. */} + + {/* The border is always present so unlocking recolors it instead of + resizing the chip. */} 1 month of Plus diff --git a/packages/shared/src/components/referral/index.ts b/packages/shared/src/components/referral/index.ts deleted file mode 100644 index 3535a46217..0000000000 --- a/packages/shared/src/components/referral/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './InviteLinkInput'; diff --git a/packages/shared/src/features/referral/components/GivebackInviteCard.tsx b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx similarity index 73% rename from packages/shared/src/features/referral/components/GivebackInviteCard.tsx rename to packages/shared/src/features/giveback/components/GivebackInviteCard.tsx index c764770ce9..6656b4cf29 100644 --- a/packages/shared/src/features/referral/components/GivebackInviteCard.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx @@ -11,18 +11,19 @@ import { ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; -import { cloudinaryCharmGiveback } from '../../../lib/image'; import { webappUrl } from '../../../lib/constants'; +import { cloudinaryCharmGiveback } from '../../../lib/image'; interface GivebackInviteCardProps { onClick?: () => void; className?: string; } -// Cross-promo into /giveback, wearing the same treatment as the giveback -// founding-reward card: a 1px gradient frame over an opaque base washed with -// the same gradient at a true 8% via color-mix (Tailwind's `/opacity` -// modifier doesn't take on these token gradient stops). +// Cross-promo into /giveback for surfaces outside the campaign (today: the +// invite settings page). It wears the same treatment as the founding-reward +// card in GivebackFoundingAward: a 1px gradient frame over an opaque base +// washed with the same gradient at a true 8% via color-mix (Tailwind's +// `/opacity` modifier doesn't take on these token gradient stops). export const GivebackInviteCard = ({ onClick, className, @@ -34,7 +35,7 @@ export const GivebackInviteCard = ({ )} >
- {/* The charm artwork sits on black; screen-blend drops the black on the - dark card. */} + {/* Not GivebackMascot: its `h-44 tablet:h-56` is baked into the same + class string a caller would override, and class names here are + concatenated rather than merged, so the hero size always wins. This + card needs a compact charm, so it sizes its own. */} + {/* The render sits on solid black; mix-blend-screen drops the black so + the charm reads as floating on the card. */} daily.dev Giveback charm diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 3c27c7f795..a1c7bb4a75 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -184,6 +184,14 @@ export const featureShortcutsHub = new Feature('shortcuts_hub_v2', false); export const featureGiveback = new Feature('giveback', isDevelopment); +// Promotes "invite 3 friends, get 1 month of Plus" on the invite settings page. +// Keep this off until the backend actually grants the free month on the third +// referral — with it off the page only describes referrals, it promises nothing. +export const featureReferralPlusReward = new Feature( + 'referral_plus_reward', + false, +); + export const featureGivebackSuggestCause = new Feature( 'giveback_suggest_cause', false, diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx index 1bb179ffde..3a7a93e818 100644 --- a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -5,9 +5,9 @@ import { format } from 'date-fns'; import { INVITE_GOAL, InviteRewardProgress, -} from '@dailydotdev/shared/src/features/referral/components/InviteRewardProgress'; -import { GivebackInviteCard } from '@dailydotdev/shared/src/features/referral/components/GivebackInviteCard'; -import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +} from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; +import { GivebackInviteCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackInviteCard'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; import UserList from '@dailydotdev/shared/src/components/profile/UserList'; import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; @@ -36,8 +36,8 @@ const noop = (): void => undefined; // can't be imported here. const SettingsCard = ({ children }: { children: ReactNode }): ReactElement => (
-
-

+
+

Invite friends

@@ -73,29 +73,45 @@ interface InvitePageProps { joinedCount?: number; isPlus?: boolean; showGiveback?: boolean; + // Mirrors the `referral_plus_reward` flag on the real page. + showReward?: boolean; } // Mirrors packages/webapp/pages/settings/invite.tsx section for section. +const rewardDescription = ( + showReward: boolean, + isUnlocked: boolean, +): string => { + if (!showReward) { + return "Share daily.dev with developers you know. When they join through your link, they'll show up in your referrals below."; + } + + return isUnlocked + ? 'Your free month of Plus is unlocked. Keep inviting — every developer you bring makes the feed sharper.' + : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; +}; + const InvitePage = ({ joinedCount = 0, isPlus = false, showGiveback = true, + showReward = true, }: InvitePageProps): ReactElement => { const referredUsers = mockReferredUsers().slice(0, joinedCount); - const isCompleted = joinedCount >= INVITE_GOAL; + const isUnlocked = joinedCount >= INVITE_GOAL; return (
- {isPlus && !isCompleted && ( + {showReward && isPlus && !isUnlocked && ( )} - + {showReward && ( + + )}
- Promise.resolve(), - className: 'mt-4 -mx-6', - }} - emptyPlaceholder={ -
- daily.dev charm inviting your friends -

- No one has joined yet. Share your link! -

-
- } - userInfoProps={{ - transformUsername({ - username, - createdAt, - }: UserShortProfile): ReactNode { - return ( - - @{username} - - - - ); - }, - }} - /> +
+ Promise.resolve(), + }} + emptyPlaceholder={ +
+ +

+ No one has joined yet. Share your link! +

+
+ } + userInfoProps={{ + transformUsername({ + username, + createdAt, + }: UserShortProfile): ReactNode { + return ( + + @{username} + + + + ); + }, + }} + /> +
); @@ -259,7 +278,7 @@ export const BeyondTheGoal: Story = { export const ExistingPlusMember: Story = { name: 'Already a Plus member', args: { joinedCount: 1, isPlus: true }, - decorators: [withInvite({ isPlus: true })], + decorators: [withInvite()], parameters: { docs: { description: { @@ -270,6 +289,20 @@ export const ExistingPlusMember: Story = { }, }; +export const RewardFlagOff: Story = { + name: 'Reward flag off (default)', + args: { joinedCount: 1, showReward: false }, + decorators: [withInvite()], + parameters: { + docs: { + description: { + story: + 'What ships until `referral_plus_reward` is switched on: no reward promise and no tracker, just the referral tooling.', + }, + }, + }, +}; + export const GivebackDisabled: Story = { name: 'Giveback flag off', args: { joinedCount: 1, showGiveback: false }, diff --git a/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx index 50cf443f1f..ab3382bba8 100644 --- a/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx +++ b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx @@ -70,10 +70,8 @@ export const mockReferredUsers = (): UserShortProfile[] => ] as UserShortProfile[]; const InviteProviders = ({ - isPlus = false, children, }: { - isPlus?: boolean; children: ReactNode; }): ReactElement => { const queryClient = useMemo(() => new QueryClient(), []); @@ -82,7 +80,7 @@ const InviteProviders = ({ return ( + (): Decorator => (Story) => ( - + ); diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx index 52aa3da4b3..f3cfe5d6ea 100644 --- a/packages/webapp/__tests__/AccountInvitePage.tsx +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -3,7 +3,7 @@ import nock from 'nock'; import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; import type { RenderResult } from '@testing-library/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql'; @@ -13,7 +13,12 @@ import { REFERRED_USERS_QUERY, } from '@dailydotdev/shared/src/graphql/users'; import type { Feature } from '@dailydotdev/shared/src/lib/featureManagement'; -import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + featureGiveback, + featureReferralPlusReward, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { LogEvent } from '@dailydotdev/shared/src/lib/log'; import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; import AccountInvitePage from '../pages/settings/invite'; @@ -79,22 +84,35 @@ const mockReferralCampaign = (referredUsersCount: number) => { }); }; -const mockReferredUsers = () => { +const referredUser = (id: string, username: string) => ({ + id, + name: username, + username, + image: `https://daily.dev/${username}.jpg`, + permalink: `https://app.daily.dev/${username}`, + createdAt: '2026-05-04T10:00:00.000Z', + reputation: 10, +}); + +const mockReferredUsers = (users: ReturnType[] = []) => { mockGraphQL({ request: { query: REFERRED_USERS_QUERY, variables: { after: '' } }, result: { data: { referredUsers: { pageInfo: { endCursor: null, hasNextPage: false }, - edges: [], + edges: users.map((node) => ({ node })), }, }, }, }); }; -const renderComponent = (user: LoggedUser = loggedUser): RenderResult => { - mockReferredUsers(); +const renderComponent = ( + user: LoggedUser = loggedUser, + users: ReturnType[] = [], +): RenderResult => { + mockReferredUsers(users); return render( @@ -120,7 +138,20 @@ const renderComponent = (user: LoggedUser = loggedUser): RenderResult => { ); }; +it('should not promise the Plus reward while the flag is off', async () => { + mockedFeatures.set(featureReferralPlusReward, false); + mockReferralCampaign(2); + renderComponent(); + + expect(await screen.findByText('Grow the community')).toBeInTheDocument(); + expect( + screen.queryByText('Invite 3 friends, get 1 month of Plus'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('2 of 3 friends joined')).not.toBeInTheDocument(); +}); + it('should render the 3-invites promo with the progress at zero', async () => { + mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(0); renderComponent(); @@ -132,6 +163,7 @@ it('should render the 3-invites promo with the progress at zero', async () => { }); it('should reflect partial progress from the referral campaign', async () => { + mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(2); renderComponent(); @@ -139,6 +171,7 @@ it('should reflect partial progress from the referral campaign', async () => { }); it('should show the unlocked state once three friends joined', async () => { + mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(3); renderComponent(); @@ -146,18 +179,49 @@ it('should show the unlocked state once three friends joined', async () => { await screen.findByText('3 of 3 friends joined — Plus unlocked'), ).toBeInTheDocument(); expect( - screen.getByText('3 friends joined. Your free month of Plus is unlocked.'), + screen.getByText(/Your free month of Plus is unlocked/), + ).toBeInTheDocument(); +}); + +it('should keep the unlocked copy count-agnostic beyond the goal', async () => { + mockedFeatures.set(featureReferralPlusReward, true); + mockReferralCampaign(12); + renderComponent(); + + expect( + await screen.findByText('3 of 3 friends joined — Plus unlocked'), ).toBeInTheDocument(); + expect(screen.queryByText(/12 of 3/)).not.toBeInTheDocument(); +}); + +it('should fill the progress slots with the referred users avatars', async () => { + mockedFeatures.set(featureReferralPlusReward, true); + mockReferralCampaign(2); + renderComponent(loggedUser, [ + referredUser('ref-1', 'idakern'), + referredUser('ref-2', 'ravimenon'), + ]); + + await screen.findByText('2 of 3 friends joined'); + await waitFor(() => { + expect(screen.getAllByAltText("idakern's profile")).not.toHaveLength(0); + }); + expect(screen.getAllByAltText("ravimenon's profile")).not.toHaveLength(0); }); -it('should link to the giveback page when the feature is enabled', async () => { +it('should link to the giveback page and log the click when enabled', async () => { mockedFeatures.set(featureGiveback, true); mockReferralCampaign(0); renderComponent(); expect(await screen.findByText('More ways to give back')).toBeInTheDocument(); - const cta = screen.getByText('Explore Giveback').closest('a'); - expect(cta).toHaveAttribute('href', expect.stringContaining('giveback')); + const cta = screen.getByRole('link', { name: 'Explore Giveback' }); + expect(cta).toHaveAttribute('href', `${webappUrl}giveback`); + + fireEvent.click(cta); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ event_name: LogEvent.ClickGivebackGiftEntry }), + ); }); it('should hide the giveback section when the feature is disabled', async () => { diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 6dae822897..9195a1a867 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useMemo, useRef } from 'react'; +import React, { useMemo } from 'react'; import { ReferralCampaignKey, useReferralCampaign, @@ -31,7 +31,7 @@ import { } from '@dailydotdev/shared/src/lib/log'; import type { ShareProvider } from '@dailydotdev/shared/src/lib/share'; import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; -import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; import type { NextSeoProps } from 'next-seo'; import { @@ -43,12 +43,15 @@ import { import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { usePlusSubscription } from '@dailydotdev/shared/src/hooks/usePlusSubscription'; import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; -import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + featureGiveback, + featureReferralPlusReward, +} from '@dailydotdev/shared/src/lib/featureManagement'; import { INVITE_GOAL, InviteRewardProgress, -} from '@dailydotdev/shared/src/features/referral/components/InviteRewardProgress'; -import { GivebackInviteCard } from '@dailydotdev/shared/src/features/referral/components/GivebackInviteCard'; +} from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; +import { GivebackInviteCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackInviteCard'; import AccountContentSection from '../../components/layouts/SettingsLayout/AccountContentSection'; import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; @@ -64,7 +67,6 @@ const seo: NextSeoProps = { const AccountInvitePage = (): ReactElement => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); - const container = useRef(); const referredKey = generateQueryKey(RequestKey.ReferredUsers, user); const { url, referredUsersCount } = useReferralCampaign({ campaignKey: ReferralCampaignKey.Generic, @@ -83,6 +85,10 @@ const AccountInvitePage = (): ReactElement => { feature: featureGiveback, shouldEvaluate: !!user, }); + const { value: isRewardEnabled } = useConditionalFeature({ + feature: featureReferralPlusReward, + shouldEvaluate: !!user, + }); const usersResult = useInfiniteQuery({ queryKey: referredKey, queryFn: ({ pageParam }) => @@ -102,10 +108,9 @@ const AccountInvitePage = (): ReactElement => { }, []); return list; - }, [usersResult]); + }, [usersResult.data]); - const joinedCount = Math.min(referredUsersCount, INVITE_GOAL); - const isCompleted = referredUsersCount >= INVITE_GOAL; + const isRewardUnlocked = referredUsersCount >= INVITE_GOAL; const onLogShare = (provider: ShareProvider) => { logEvent({ @@ -115,6 +120,17 @@ const AccountInvitePage = (): ReactElement => { }); }; + const getRewardDescription = () => { + if (!isRewardEnabled) { + return "Share daily.dev with developers you know. When they join through your link, they'll show up in your referrals below."; + } + + return isRewardUnlocked + ? 'Your free month of Plus is unlocked. Keep inviting — every developer you bring makes the feed sharper.' + : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; + }; + const rewardDescription = getRewardDescription(); + const onGivebackClick = () => { logEvent({ event_name: LogEvent.ClickGivebackGiftEntry, @@ -126,14 +142,14 @@ const AccountInvitePage = (): ReactElement => { - {isPlus && !isCompleted && ( + {isRewardEnabled && isPlus && !isRewardUnlocked && ( { Already on Plus? The free month is added to your subscription. )} - + {isRewardEnabled && ( + + )} { title="Your referrals" description="Developers who joined through your invite link" > - - daily.dev charm inviting your friends -

- No one has joined yet. Share your link! -

-

- } - userInfoProps={{ - scrollingContainer: container.current, - transformUsername({ - username, - createdAt, - }: UserShortProfile): React.ReactNode { - return ( - - @{username} - - - - ); - }, - }} - placeholderAmount={referredUsersCount} - /> + {/* UserList hard-codes px-6 on its rows and on the loading placeholder, + so cancel the section's own p-6 around the whole list. That keeps + every state (rows, skeletons, empty) aligned with the headings and + lets the row hover highlight run the full width of the card. */} +
+ + +

+ No one has joined yet. Share your link! +

+
+ } + userInfoProps={{ + transformUsername({ + username, + createdAt, + }: UserShortProfile): React.ReactNode { + return ( + + @{username} + + + + ); + }, + }} + placeholderAmount={referredUsersCount} + /> +
); diff --git a/packages/webapp/pages/settings/organization/index.tsx b/packages/webapp/pages/settings/organization/index.tsx index 1e486bd6bd..aecfd20fcf 100644 --- a/packages/webapp/pages/settings/organization/index.tsx +++ b/packages/webapp/pages/settings/organization/index.tsx @@ -28,7 +28,7 @@ import { OrganizationIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; import { ReferralCampaignKey, From 2093aa203c6a527ca3579076864122699bdc51ab Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 11:49:43 +0300 Subject: [PATCH 08/16] fix(giveback): keep the invite card charm visible in light mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The charm render sits on solid black and needs mix-blend-screen to drop it, which only works over a dark surface — on the light theme it blended away entirely. Give it its own tile in the artwork's own black so the treatment holds in both themes. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackInviteCard.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx index 6656b4cf29..f11e1964f0 100644 --- a/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx @@ -70,14 +70,17 @@ export const GivebackInviteCard = ({ {/* Not GivebackMascot: its `h-44 tablet:h-56` is baked into the same class string a caller would override, and class names here are concatenated rather than merged, so the hero size always wins. This - card needs a compact charm, so it sizes its own. */} - + card needs a compact charm, so it sizes its own. + + The render sits on solid black and needs mix-blend-screen to drop it, + which only works over a dark surface — on the light theme the charm + would blend away entirely. So it gets its own tile in the artwork's + own black (raw-pepper-90), constant across themes. */} + - {/* The render sits on solid black; mix-blend-screen drops the black so - the charm reads as floating on the card. */} Date: Thu, 23 Jul 2026 12:00:29 +0300 Subject: [PATCH 09/16] feat(referral): celebrate the unlocked Plus month and notify on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unlocked state only recoloured a chip border, so hitting the goal looked the same as missing it. Once the goal is met the tracker now becomes a full-width Plus card that names the reward, marks it earned and states the period the free month covers. The period is derived client-side from the join date of the third referral; no backend field carries the grant yet. Adds the two notification types the feature would emit — a per-join "friend joined through your invite" and the one-off "free month unlocked" — with their icon/category mapping and rows in the notifications page mock. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 21 +++ .../src/components/notifications/utils.ts | 9 + .../referral/InviteRewardProgress.tsx | 163 ++++++++++++------ .../components/notifications/_mock.tsx | 35 ++++ .../referral/InviteFriends.stories.tsx | 50 +++++- .../webapp/__tests__/AccountInvitePage.tsx | 32 +++- packages/webapp/pages/settings/invite.tsx | 30 +++- 7 files changed, 281 insertions(+), 59 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..3fb63337e5 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,21 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "storybook", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "--filter", + "storybook", + "exec", + "storybook", + "dev", + "-p", + "6013", + "--no-open" + ], + "port": 6013, + "autoPort": false + } + ] +} diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index ecc1a94c9c..0ad8c30a4d 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -22,6 +22,7 @@ import { AddUserIcon, SquadIcon, MegaphoneIcon, + DevPlusIcon, } from '../icons'; import type { NotificationPromptSource } from '../../lib/log'; import { BookmarkReminderIcon } from '../icons/Bookmark/Reminder'; @@ -98,6 +99,8 @@ export enum NotificationType { WarmIntro = 'warm_intro', ExperienceCompanyEnriched = 'experience_company_enriched', LiveRoomStarted = 'live_room_started', + ReferralFriendJoined = 'referral_friend_joined', + ReferralPlusUnlocked = 'referral_plus_unlocked', } export enum NotificationIconType { @@ -118,6 +121,7 @@ export enum NotificationIconType { Core = 'Core', Analytics = 'Analytics', Opportunity = 'Opportunity', + DevPlus = 'DevPlus', } export const notificationIcon: Record< @@ -141,6 +145,7 @@ export const notificationIcon: Record< [NotificationIconType.Core]: CoreIcon, [NotificationIconType.Analytics]: AnalyticsIcon, [NotificationIconType.Opportunity]: JobIcon, + [NotificationIconType.DevPlus]: DevPlusIcon, }; export const notificationIconAsPrimary: NotificationIconType[] = [ @@ -166,6 +171,7 @@ export const notificationIconTypeTheme: Record = { [NotificationIconType.Core]: '', [NotificationIconType.Analytics]: 'text-brand-default', [NotificationIconType.Opportunity]: 'text-black', + [NotificationIconType.DevPlus]: 'text-action-plus-default', }; export const notificationIconStyle: Record< @@ -189,6 +195,7 @@ export const notificationIconStyle: Record< [NotificationIconType.Core]: null, [NotificationIconType.Analytics]: null, [NotificationIconType.Opportunity]: { background: briefButtonBg }, + [NotificationIconType.DevPlus]: null, }; export const notificationTypeTheme: Partial> = @@ -430,6 +437,8 @@ export const notificationCategoryToTypes: Record< NotificationType.WarmIntro, NotificationType.ExperienceCompanyEnriched, NotificationType.LiveRoomStarted, + NotificationType.ReferralFriendJoined, + NotificationType.ReferralPlusUnlocked, ], }; diff --git a/packages/shared/src/components/referral/InviteRewardProgress.tsx b/packages/shared/src/components/referral/InviteRewardProgress.tsx index e00734ce2b..42ee634d0b 100644 --- a/packages/shared/src/components/referral/InviteRewardProgress.tsx +++ b/packages/shared/src/components/referral/InviteRewardProgress.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; +import { format } from 'date-fns'; import { Typography, TypographyColor, @@ -13,28 +14,131 @@ import type { UserShortProfile } from '../../lib/user'; export const INVITE_GOAL = 3; +export interface InviteRewardPeriod { + startsAt: Date; + endsAt: Date; +} + interface InviteRewardProgressProps { // The raw number of developers who joined. Values outside 0..INVITE_GOAL are // clamped here, so callers can pass the referral count as-is. joinedCount: number; referredUsers: UserShortProfile[]; + // When the free month runs. Optional: the referral list can still be loading + // when the count already says the goal is met, and the unlocked state reads + // fine without it. + rewardPeriod?: InviteRewardPeriod; className?: string; } -// The referral reward tracker: one slot per friend needed, filled with the -// avatars of the developers who actually joined, ending in the Plus reward. -// Slots use the same rounded-rect radius as ProfilePicture so filled and empty -// slots share a silhouette. +const formatRewardDate = (date: Date): string => format(date, 'd MMM yyyy'); + +// The row of avatars/slots, one per friend needed. Filled slots use the same +// rounded-rect radius as ProfilePicture so filled and empty share a silhouette. +const InviteSlots = ({ + joined, + referredUsers, +}: { + joined: number; + referredUsers: UserShortProfile[]; +}): ReactElement => ( + // The caption states the progress and every referred developer is named in + // the list further down the page, so the slot row is presentational. +
+ {Array.from({ length: INVITE_GOAL }, (_, index) => { + const isFilled = index < joined; + const referredUser = referredUsers[index]; + + if (isFilled && referredUser) { + return ( + + ); + } + + return ( + + {isFilled ? : } + + ); + })} +
+); + +// The referral reward tracker. While the goal is open it stays a quiet one-line +// row; once it's met the reward is the point of the section, so it becomes a +// full-width Plus-accented card that names the reward and when it runs. export const InviteRewardProgress = ({ joinedCount, referredUsers, + rewardPeriod, className, }: InviteRewardProgressProps): ReactElement => { const joined = Math.min(Math.max(0, joinedCount), INVITE_GOAL); const isCompleted = joined >= INVITE_GOAL; - const caption = isCompleted - ? `${INVITE_GOAL} of ${INVITE_GOAL} friends joined — Plus unlocked` - : `${joined} of ${INVITE_GOAL} friends joined`; + + if (isCompleted) { + return ( +
+
+ + + + + {/* The "earned it" mark, notched into the corner the way a badge + sits on an avatar. */} + + + + +
+ + 1 month of Plus unlocked + + {rewardPeriod && ( + + + {' – '} + + + )} +
+
+
+ + + All {INVITE_GOAL} friends joined + +
+
+ ); + } return (
@@ -42,51 +146,14 @@ export const InviteRewardProgress = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - {caption} + {joined} of {INVITE_GOAL} friends joined - {/* The caption above already states the progress, and every referred - developer is named in the list further down the page, so the slot row - is presentational. */} -
- {Array.from({ length: INVITE_GOAL }, (_, index) => { - const isFilled = index < joined; - const referredUser = referredUsers[index]; - - if (isFilled && referredUser) { - return ( - - ); - } - - return ( - - {isFilled ? : } - - ); - })} +
+ {/* The connector is the first thing to go on narrow phones, where the slots and the reward chip only just fit on one line. */} - {/* The border is always present so unlocking recolors it instead of - resizing the chip. */} - + 1 month of Plus
diff --git a/packages/storybook/stories/components/notifications/_mock.tsx b/packages/storybook/stories/components/notifications/_mock.tsx index 6815592a04..b0beffc057 100644 --- a/packages/storybook/stories/components/notifications/_mock.tsx +++ b/packages/storybook/stories/components/notifications/_mock.tsx @@ -1,4 +1,5 @@ import { fn } from 'storybook/test'; +import { addMonths, format } from 'date-fns'; import type { NotificationItemProps } from '@dailydotdev/shared/src/components/notifications/NotificationItem'; import { NotificationIconType, @@ -22,6 +23,9 @@ const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000); const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000); const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000); +// The referral reward runs from the moment the third friend joined. +const rewardStart = hoursAgo(5); + export const userAvatar = (seed: string, name: string) => ({ type: NotificationAvatarType.User, referenceId: seed, @@ -84,6 +88,28 @@ const defs: Array & { isUnread?: boolean }> = [ createdAt: hoursAgo(3), isUnread: true, }, + { + type: NotificationType.ReferralPlusUnlocked, + icon: NotificationIconType.DevPlus, + title: 'Your free month of Plus is unlocked — 3 friends joined', + description: `Active ${format(rewardStart, 'd MMM yyyy')} – ${format( + addMonths(rewardStart, 1), + 'd MMM yyyy', + )}`, + targetUrl: '/settings/invite', + createdAt: hoursAgo(5), + isUnread: true, + }, + { + type: NotificationType.ReferralFriendJoined, + icon: NotificationIconType.User, + title: 'Wren Halloway joined daily.dev through your invite', + description: '3 of 3 friends joined', + avatars: [userAvatar('wren', 'Wren Halloway')], + targetUrl: '/settings/invite', + createdAt: hoursAgo(5), + isUnread: true, + }, { type: NotificationType.UserFollow, icon: NotificationIconType.User, @@ -183,6 +209,15 @@ export const sampleNotifications: Array< ...def, })); +// The referral-reward rows, pulled out so the Invite friends stories can show +// them on their own without re-declaring the copy. +export const referralNotifications = sampleNotifications.filter(({ type }) => + [ + NotificationType.ReferralPlusUnlocked, + NotificationType.ReferralFriendJoined, + ].includes(type), +); + // Same coarse buckets the live page uses (see NotificationsFeed.tsx). export const TIME_GROUPS = [ { key: 'today', label: 'Today', maxDays: 0 }, diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx index 3a7a93e818..da4bcadce2 100644 --- a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; -import { format } from 'date-fns'; +import { addMonths, format } from 'date-fns'; +import type { InviteRewardPeriod } from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; import { INVITE_GOAL, InviteRewardProgress, @@ -22,6 +23,8 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { referralNotifications } from '../../components/notifications/_mock'; import { INVITE_LINK, mockReferredUsers, @@ -87,10 +90,26 @@ const rewardDescription = ( } return isUnlocked - ? 'Your free month of Plus is unlocked. Keep inviting — every developer you bring makes the feed sharper.' + ? 'Keep inviting — every developer you bring makes the feed sharper.' : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; }; +// Same derivation as the page: the free month runs from the join date of the +// INVITE_GOALth developer. +const rewardPeriodFor = ( + referredUsers: UserShortProfile[], +): InviteRewardPeriod | undefined => { + const completing = referredUsers[INVITE_GOAL - 1]; + + if (!completing) { + return undefined; + } + + const startsAt = new Date(completing.createdAt); + + return { startsAt, endsAt: addMonths(startsAt, 1) }; +}; + const InvitePage = ({ joinedCount = 0, isPlus = false, @@ -125,6 +144,7 @@ const InvitePage = ({ className="mt-4" joinedCount={joinedCount} referredUsers={referredUsers} + rewardPeriod={rewardPeriodFor(referredUsers)} /> )} @@ -329,6 +349,7 @@ export const ProgressStates: StoryObj = {
))} @@ -338,6 +359,31 @@ export const ProgressStates: StoryObj = { parameters: { controls: { disable: true } }, }; +// The two notifications this feature emits, in the row layout the +// /notifications page renders them in. They also sit in the shared +// notifications mock, so "Components/Notifications/Full page" shows them in +// context alongside the rest of the feed. +export const Notifications: StoryObj = { + name: 'Notifications', + render: () => ( +
+ {referralNotifications.map((notification) => ( + + ))} +
+ ), + decorators: [withInvite()], + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Both rows deep-link to /settings/invite. `referral_friend_joined` fires per join and carries the running count; `referral_plus_unlocked` fires once on the third and states the period the free month covers. Unread rows sit on the raised surface, same as the rest of the feed. Neither type is emitted by the backend yet.', + }, + }, + }, +}; + export const GivebackCard: StoryObj = { name: 'Giveback cross-promo card', render: () => ( diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx index f3cfe5d6ea..2ed1f8b97f 100644 --- a/packages/webapp/__tests__/AccountInvitePage.tsx +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -84,13 +84,17 @@ const mockReferralCampaign = (referredUsersCount: number) => { }); }; -const referredUser = (id: string, username: string) => ({ +const referredUser = ( + id: string, + username: string, + createdAt = '2026-05-04T10:00:00.000Z', +) => ({ id, name: username, username, image: `https://daily.dev/${username}.jpg`, permalink: `https://app.daily.dev/${username}`, - createdAt: '2026-05-04T10:00:00.000Z', + createdAt, reputation: 10, }); @@ -176,11 +180,9 @@ it('should show the unlocked state once three friends joined', async () => { renderComponent(); expect( - await screen.findByText('3 of 3 friends joined — Plus unlocked'), - ).toBeInTheDocument(); - expect( - screen.getByText(/Your free month of Plus is unlocked/), + await screen.findByText('1 month of Plus unlocked'), ).toBeInTheDocument(); + expect(screen.getByText('All 3 friends joined')).toBeInTheDocument(); }); it('should keep the unlocked copy count-agnostic beyond the goal', async () => { @@ -189,11 +191,27 @@ it('should keep the unlocked copy count-agnostic beyond the goal', async () => { renderComponent(); expect( - await screen.findByText('3 of 3 friends joined — Plus unlocked'), + await screen.findByText('1 month of Plus unlocked'), ).toBeInTheDocument(); + expect(screen.getByText('All 3 friends joined')).toBeInTheDocument(); expect(screen.queryByText(/12 of 3/)).not.toBeInTheDocument(); }); +it('should run the free month from the join date of the third referral', async () => { + mockedFeatures.set(featureReferralPlusReward, true); + mockReferralCampaign(3); + renderComponent(loggedUser, [ + // Deliberately out of order — the period is derived from the third + // developer to join, not the third row the query returns. + referredUser('ref-3', 'wrenh', '2026-07-02T10:00:00.000Z'), + referredUser('ref-1', 'idakern', '2026-05-04T10:00:00.000Z'), + referredUser('ref-2', 'ravimenon', '2026-06-11T10:00:00.000Z'), + ]); + + expect(await screen.findByText('2 Jul 2026')).toBeInTheDocument(); + expect(screen.getByText('2 Aug 2026')).toBeInTheDocument(); +}); + it('should fill the progress slots with the referred users avatars', async () => { mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(2); diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 9195a1a867..21e5f6b925 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -22,7 +22,7 @@ import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; -import { format } from 'date-fns'; +import { addMonths, format } from 'date-fns'; import { useInfiniteQuery } from '@tanstack/react-query'; import { LogEvent, @@ -112,6 +112,29 @@ const AccountInvitePage = (): ReactElement => { const isRewardUnlocked = referredUsersCount >= INVITE_GOAL; + // The free month runs from the moment the goal was met — i.e. the join date + // of the INVITE_GOALth developer. Derived on the client because no backend + // field carries the grant yet; when one lands, read it here instead. Sorts a + // copy rather than trusting the list order, which the query doesn't specify. + const rewardPeriod = useMemo(() => { + if (!isRewardUnlocked) { + return undefined; + } + + const completing = [...users].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + )[INVITE_GOAL - 1]; + + if (!completing) { + return undefined; + } + + const startsAt = new Date(completing.createdAt); + + return { startsAt, endsAt: addMonths(startsAt, 1) }; + }, [isRewardUnlocked, users]); + const onLogShare = (provider: ShareProvider) => { logEvent({ event_name: LogEvent.InviteReferral, @@ -125,8 +148,10 @@ const AccountInvitePage = (): ReactElement => { return "Share daily.dev with developers you know. When they join through your link, they'll show up in your referrals below."; } + // The card below already announces the reward, so the unlocked copy only + // has to give a reason to keep going. return isRewardUnlocked - ? 'Your free month of Plus is unlocked. Keep inviting — every developer you bring makes the feed sharper.' + ? 'Keep inviting — every developer you bring makes the feed sharper.' : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; }; const rewardDescription = getRewardDescription(); @@ -163,6 +188,7 @@ const AccountInvitePage = (): ReactElement => { className="mt-4" joinedCount={referredUsersCount} referredUsers={users} + rewardPeriod={rewardPeriod} /> )} From 294ede2c129c60b2ff2d059fe6cceb8db636d67d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 12:05:28 +0300 Subject: [PATCH 10/16] fix(giveback): cut the charm's black out instead of plating it The dark tile made the card carry a box it didn't need. Derive the alpha channel from the artwork's own brightness instead, so the black background falls out and the charm reads on any surface without a blend mode or a backdrop. Also drops the em dashes from this feature's copy. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackInviteCard.tsx | 35 +++++++++++++------ .../components/notifications/_mock.tsx | 2 +- .../referral/InviteFriends.stories.tsx | 2 +- packages/webapp/pages/settings/invite.tsx | 2 +- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx index f11e1964f0..fcd2c2143c 100644 --- a/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInviteCard.tsx @@ -19,6 +19,8 @@ interface GivebackInviteCardProps { className?: string; } +const charmCutoutId = 'giveback-charm-cutout'; + // Cross-promo into /giveback for surfaces outside the campaign (today: the // invite settings page). It wears the same treatment as the founding-reward // card in GivebackFoundingAward: a 1px gradient frame over an opaque base @@ -53,8 +55,8 @@ export const GivebackInviteCard = ({ color={TypographyColor.Secondary} className="[text-wrap:pretty]" > - We redirect our growth budget to causes the community picks — you - never pay a cent. + We redirect our growth budget to causes the community picks. You never + pay a cent.
diff --git a/packages/storybook/stories/components/notifications/_mock.tsx b/packages/storybook/stories/components/notifications/_mock.tsx index b0beffc057..d75ce10d35 100644 --- a/packages/storybook/stories/components/notifications/_mock.tsx +++ b/packages/storybook/stories/components/notifications/_mock.tsx @@ -91,7 +91,7 @@ const defs: Array & { isUnread?: boolean }> = [ { type: NotificationType.ReferralPlusUnlocked, icon: NotificationIconType.DevPlus, - title: 'Your free month of Plus is unlocked — 3 friends joined', + title: '3 friends joined, so your free month of Plus is unlocked', description: `Active ${format(rewardStart, 'd MMM yyyy')} – ${format( addMonths(rewardStart, 1), 'd MMM yyyy', diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx index da4bcadce2..ed505eeed2 100644 --- a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -90,7 +90,7 @@ const rewardDescription = ( } return isUnlocked - ? 'Keep inviting — every developer you bring makes the feed sharper.' + ? 'Keep inviting. Every developer you bring makes the feed sharper.' : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; }; diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index 21e5f6b925..dea9ffee1a 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -151,7 +151,7 @@ const AccountInvitePage = (): ReactElement => { // The card below already announces the reward, so the unlocked copy only // has to give a reason to keep going. return isRewardUnlocked - ? 'Keep inviting — every developer you bring makes the feed sharper.' + ? 'Keep inviting. Every developer you bring makes the feed sharper.' : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; }; const rewardDescription = getRewardDescription(); From 1d72dc5b7307575f09689303ea41713b4bc795dd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 12:09:36 +0300 Subject: [PATCH 11/16] chore(referral): drop em dashes from the Invite friends story names Co-Authored-By: Claude Opus 4.8 --- .../features/referral/InviteFriends.stories.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx index ed505eeed2..09057781e2 100644 --- a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -240,7 +240,7 @@ const meta: Meta = { docs: { description: { component: - 'Every state of the "Invite friends" settings page (/settings/invite): referral progress from zero to unlocked, the Plus-member variant, and the giveback cross-promo on and off. The reward chip is framing only — no backend grants Plus at 3 referrals yet.', + 'Every state of the "Invite friends" settings page (/settings/invite): referral progress from zero to unlocked, the Plus-member variant, and the giveback cross-promo on and off. The reward chip is framing only: no backend grants Plus at 3 referrals yet.', }, }, }, @@ -258,31 +258,31 @@ export default meta; type Story = StoryObj; export const NoInvitesYet: Story = { - name: '0 of 3 — nobody joined', + name: '0 of 3 (nobody joined)', args: { joinedCount: 0 }, decorators: [withInvite()], }; export const OneFriendJoined: Story = { - name: '1 of 3 — first join', + name: '1 of 3 (first join)', args: { joinedCount: 1 }, decorators: [withInvite()], }; export const TwoFriendsJoined: Story = { - name: '2 of 3 — one to go', + name: '2 of 3 (one to go)', args: { joinedCount: 2 }, decorators: [withInvite()], }; export const RewardUnlocked: Story = { - name: '3 of 3 — Plus unlocked', + name: '3 of 3 (Plus unlocked)', args: { joinedCount: 3 }, decorators: [withInvite()], }; export const BeyondTheGoal: Story = { - name: '4 joined — past the goal', + name: '4 joined (past the goal)', args: { joinedCount: 4 }, decorators: [withInvite()], parameters: { @@ -338,7 +338,7 @@ export const Mobile: Story = { // Component-level states, side by side, for judging the tracker on its own. export const ProgressStates: StoryObj = { - name: 'Reward progress — all steps', + name: 'Reward progress (all steps)', render: () => (
{[0, 1, 2, 3].map((count) => ( From 874813c9ba3fff5e41ec61b5ddde0c89acfa4268 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 12:54:04 +0300 Subject: [PATCH 12/16] style(referral): tighten the unlocked reward card Smaller headline and reward tile, less padding, and a one-size-down avatar row in the footer, so the card sits at the same weight as the other cards on the page. Co-Authored-By: Claude Opus 4.8 --- .../referral/InviteRewardProgress.tsx | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/referral/InviteRewardProgress.tsx b/packages/shared/src/components/referral/InviteRewardProgress.tsx index 42ee634d0b..6531210d27 100644 --- a/packages/shared/src/components/referral/InviteRewardProgress.tsx +++ b/packages/shared/src/components/referral/InviteRewardProgress.tsx @@ -38,13 +38,23 @@ const formatRewardDate = (date: Date): string => format(date, 'd MMM yyyy'); const InviteSlots = ({ joined, referredUsers, + // The unlocked card keeps the row as a footnote under the reward, so it runs + // one size down from the standalone tracker. + isCompact = false, }: { joined: number; referredUsers: UserShortProfile[]; + isCompact?: boolean; }): ReactElement => ( // The caption states the progress and every referred developer is named in // the list further down the page, so the slot row is presentational. -
+
{Array.from({ length: INVITE_GOAL }, (_, index) => { const isFilled = index < joined; const referredUser = referredUsers[index]; @@ -54,7 +64,7 @@ const InviteSlots = ({ ); } @@ -63,7 +73,8 @@ const InviteSlots = ({ -
+
- - + + {/* The "earned it" mark, notched into the corner the way a badge sits on an avatar. */} - + -
- +
+ 1 month of Plus unlocked {rewardPeriod && ( @@ -127,8 +138,12 @@ export const InviteRewardProgress = ({ )}
-
- +
+ Date: Thu, 23 Jul 2026 12:55:20 +0300 Subject: [PATCH 13/16] style(referral): flatten the unlocked reward card Drop the accent border and let the tinted background carry the card on its own, matching the flat treatment the rest of the page uses. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/referral/InviteRewardProgress.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/referral/InviteRewardProgress.tsx b/packages/shared/src/components/referral/InviteRewardProgress.tsx index 6531210d27..64999c21c1 100644 --- a/packages/shared/src/components/referral/InviteRewardProgress.tsx +++ b/packages/shared/src/components/referral/InviteRewardProgress.tsx @@ -103,7 +103,7 @@ export const InviteRewardProgress = ({ return (
From 46b20f7a91e23c111e5503ad5cc2742001bc5d93 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 15:26:18 +0300 Subject: [PATCH 14/16] feat(referral): split copy/share control, always-on Plus promo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the invite link's copy button with the split control from the sharing-visibility work: the left half copies with a cross-fade confirmation, the chevron drops the standard share menu. Its own DS border is the seam, and both halves tighten one padding step so the hairline is not sitting in a canyon. The menu holds the same eight networks the "or invite via" row did, so that row goes; keeping both duplicated it 40px apart. The split halves stop their own clicks — the field container focuses its input on click, which dismissed the menu the instant it opened. Drop `referral_plus_reward` with it: the "invite 3 friends, get 1 month of Plus" promo, its tracker and the Plus-member note now ship to everyone, and the "Grow the community" fallback and its test go away. Co-Authored-By: Claude Opus 4.8 --- .../components/referral/InviteLinkInput.tsx | 22 +- .../components/share/ShareActions.spec.tsx | 81 ++++ .../src/components/share/ShareActions.tsx | 390 ++++++++++++++++++ packages/shared/src/lib/featureManagement.ts | 8 - .../referral/InviteFriends.stories.tsx | 136 +++--- .../features/referral/inviteFriends.mocks.tsx | 3 + .../webapp/__tests__/AccountInvitePage.tsx | 23 +- packages/webapp/pages/settings/invite.tsx | 113 +++-- 8 files changed, 610 insertions(+), 166 deletions(-) create mode 100644 packages/shared/src/components/share/ShareActions.spec.tsx create mode 100644 packages/shared/src/components/share/ShareActions.tsx diff --git a/packages/shared/src/components/referral/InviteLinkInput.tsx b/packages/shared/src/components/referral/InviteLinkInput.tsx index 1d36abb6f5..77abff4202 100644 --- a/packages/shared/src/components/referral/InviteLinkInput.tsx +++ b/packages/shared/src/components/referral/InviteLinkInput.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import type { TextFieldProps } from '../fields/TextField'; import { TextField } from '../fields/TextField'; import { useCopyLink } from '../../hooks/useCopy'; import { useLogContext } from '../../contexts/LogContext'; @@ -18,6 +19,10 @@ interface InviteLinkInputProps { onCopy?: () => void; className?: FieldClassName; logProps: LogEvent; + // Replaces the built-in copy button, for surfaces that need a richer control + // (e.g. the split copy/share button). Optional so every other consumer keeps + // its original button and copy handling. + actionButton?: TextFieldProps['actionButton']; } export function InviteLinkInput({ @@ -26,6 +31,7 @@ export function InviteLinkInput({ onCopy, className, logProps, + actionButton, }: InviteLinkInputProps): ReactElement { const [copied, onCopyLink] = useCopyLink(() => link); const { logEvent } = useLogContext(); @@ -56,13 +62,15 @@ export function InviteLinkInput({ value={link} fieldType="tertiary" actionButton={ - + actionButton ?? ( + + ) } readOnly /> diff --git a/packages/shared/src/components/share/ShareActions.spec.tsx b/packages/shared/src/components/share/ShareActions.spec.tsx new file mode 100644 index 0000000000..5a86a8de95 --- /dev/null +++ b/packages/shared/src/components/share/ShareActions.spec.tsx @@ -0,0 +1,81 @@ +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 { ShareActions } from './ShareActions'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { ShareProvider } from '../../lib/share'; +import { useViewSize } from '../../hooks/useViewSize'; + +jest.mock('../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const onShare = jest.fn(); +const link = 'https://daily.dev/posts/abc'; +const text = 'Check this out'; + +beforeEach(() => { + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); // default: laptop + Object.assign(navigator, { + clipboard: { writeText }, + }); +}); + +const renderComponent = ( + props: Partial[0]> = {}, +): RenderResult => { + const client = new QueryClient(); + return render( + + + , + ); +}; + +describe('ShareActions inline variant', () => { + it('renders copy link plus a compact set of social networks', () => { + renderComponent({ variant: 'inline' }); + + expect(screen.getByText('Copy link')).toBeInTheDocument(); + expect(screen.getByText('X')).toBeInTheDocument(); + expect(screen.getByText('WhatsApp')).toBeInTheDocument(); + }); + + it('copies the link and reports the CopyLink provider', async () => { + renderComponent({ variant: 'inline' }); + + await act(async () => { + fireEvent.click(screen.getByText('Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink); + }); +}); + +describe('ShareActions icon variant on mobile', () => { + beforeEach(() => useViewSizeMock.mockReturnValue(false)); + + it('copies on a single tap when native share is unavailable', async () => { + renderComponent(); + + const trigger = screen.getByLabelText('Copy link'); + await act(async () => { + fireEvent.click(trigger); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink); + }); +}); diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx new file mode 100644 index 0000000000..8f27d09cdf --- /dev/null +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -0,0 +1,390 @@ +import type { ReactElement } from 'react'; +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; +import { PopoverContent } from '../popover/Popover'; +import { SocialShareList } from '../widgets/SocialShareList'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon, CopyIcon, VIcon } from '../icons'; +import type { IconProps } from '../Icon'; +import { IconSize } from '../Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { Tooltip } from '../tooltip/Tooltip'; +import { Typography, TypographyType } from '../typography/Typography'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { shouldUseNativeShare } from '../../lib/func'; +import { ShareProvider } from '../../lib/share'; +import type { ReferralCampaignKey } from '../../lib/referral'; + +export type ShareActionsVariant = 'icon' | 'inline' | 'split'; + +export interface ShareActionsProps { + link: string; + /** Share text / description used for native share + pre-filled network text. */ + text: string; + cid?: ReferralCampaignKey; + variant?: ShareActionsVariant; + /** Desktop only: reveal the popover on hover as well as click. */ + openOnHover?: boolean; + buttonVariant?: ButtonVariant; + buttonSize?: ButtonSize; + /** Tooltip + accessible label for the icon-only trigger. */ + label?: string; + /** + * Render `triggerText` beside the icon instead of an icon-only trigger. Gated + * so existing icon-only consumers keep their exact DOM. + */ + triggerText?: string; + /** `split` variant only: label for the chevron half that opens the list. */ + dropdownLabel?: string; + emailTitle?: string; + emailSummary?: string; + /** Off for links that are already short and carry their own attribution. */ + shortenUrl?: boolean; + className?: string; + /** Called for any share/copy so the caller can log with its own origin. */ + onShare?: (provider: ShareProvider) => void; +} + +const HOVER_CLOSE_DELAY = 120; + +/** + * easeOutExpo — the same curve the design-system dropdown animates on. It + * decelerates into the target with no overshoot, which is what keeps a swap + * from reading as a wobble. + */ +const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; + +/** + * The halves meet at a single hairline, so the standard side paddings would + * leave a canyon around it. Both sides tighten by one step from the DS value — + * the outer edges keep the standard padding, so the control still reads as one + * button. + */ +const SPLIT_MAIN_INNER_PADDING: Record = { + [ButtonSize.XLarge]: '!pr-5', + [ButtonSize.Large]: '!pr-4', + [ButtonSize.Medium]: '!pr-3', + [ButtonSize.Small]: '!pr-2', + [ButtonSize.XSmall]: '!pr-1.5', +}; + +const DIVIDER_BASE = + "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; + +/** + * Variants that paint `--button-default-border-color: transparent` have no + * border for the divider to match, so they draw their own 1px rule: + * + * - `Primary` sits on a solid fill, where only the label colour is guaranteed + * to read, and runs full height like a real border would. + * - `Tertiary` is a bare ghost button, so a full-height rule floats with + * nothing to anchor it. It gets a shorter rule in the same colour the + * `Subtle` variant borders with — `border-subtlest-primary` at 30 %, per + * `tailwind/buttons.ts`. + * + * Every other variant returns nothing and keeps its real border as the divider. + */ +const dividerFor = (buttonVariant: ButtonVariant): string | false => { + if (buttonVariant === ButtonVariant.Primary) { + return classNames( + DIVIDER_BASE, + // Alpha is mixed into the colour rather than applied as a separate + // opacity utility: `before:opacity-*` does not survive this project's + // Tailwind build on pseudo-elements, and silently renders at full + // strength. + 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', + ); + } + + if (buttonVariant === ButtonVariant.Tertiary) { + return classNames( + DIVIDER_BASE, + // The literal expression `tailwind/buttons.ts` gives the Subtle variant's + // border, rather than a token plus an opacity utility that only + // approximates it. + 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', + ); + } + + return false; +}; + +/** + * The split halves keep their clicks to themselves: a parent that acts on + * clicks steals focus the moment the menu opens, which dismisses it right away + * (e.g. a TextField, whose container focuses its input on click). + */ +const stopParentClick = (event: React.MouseEvent): void => + event.stopPropagation(); + +/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ +const SPLIT_CHEVRON_PADDING: Record = { + [ButtonSize.XLarge]: '!w-auto !px-3', + [ButtonSize.Large]: '!w-auto !px-2.5', + [ButtonSize.Medium]: '!w-auto !px-2', + [ButtonSize.Small]: '!w-auto !px-1.5', + [ButtonSize.XSmall]: '!w-auto !px-1', +}; + +/** + * A copy is a rare, deliberate moment, so the confirmation earns real motion: + * the two glyphs cross-fade in place on opacity + scale + blur (all + * compositor-only) rather than snapping. They share one grid cell so the label + * never shifts mid-swap, and the whole thing collapses to an instant swap under + * `prefers-reduced-motion`. + */ +const CopyStateIcon = ({ + copied, + className, + ...props +}: IconProps & { copied: boolean }): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; + +export function ShareActions({ + link, + text, + cid, + variant = 'icon', + openOnHover = false, + buttonVariant = ButtonVariant.Tertiary, + buttonSize = ButtonSize.Small, + label = 'Copy link', + triggerText, + dropdownLabel = 'More share options', + emailTitle, + emailSummary, + shortenUrl = true, + className, + onShare, +}: ShareActionsProps): ReactElement { + const isLaptop = useViewSize(ViewSize.Laptop); + const [open, setOpen] = useState(false); + const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); + const closeTimeout = useRef>(); + + // `copying` stays true for a second after a copy, which is the whole window + // for the confirmation swap. + const copyIcon = ; + + const list = ( + { + onShare?.(ShareProvider.CopyLink); + shareOrCopy(); + }} + onNativeShare={() => { + onShare?.(ShareProvider.Native); + shareOrCopy(); + }} + onClickSocial={(provider) => onShare?.(provider)} + /> + ); + + if (variant === 'inline') { + return ( +
+ {list} +
+ ); + } + + // Mobile: a single tap goes straight to the native share sheet (or copy when + // native share is unavailable) — no popover, per sharing UX guidance. + if (!isLaptop) { + return ( + + + + ); + } + + const cancelClose = () => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current); + } + }; + const hoverProps = openOnHover + ? { + onMouseEnter: () => { + cancelClose(); + setOpen(true); + }, + onMouseLeave: () => { + closeTimeout.current = setTimeout( + () => setOpen(false), + HOVER_CLOSE_DELAY, + ); + }, + } + : undefined; + + const shareList = ( + <> + + Share + + {list} + + ); + + // Two real buttons that read as one control: the left half copies straight + // away, the right half drops the standard share menu. + if (variant === 'split') { + return ( +
+ + + + + +
+ ); + } + + return ( + + + + + + + + {shareList} + + + ); +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 582ee4ec70..8ca5104a8e 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -195,14 +195,6 @@ export const featureShortcutsHub = new Feature('shortcuts_hub_v2', false); export const featureGiveback = new Feature('giveback', isDevelopment); -// Promotes "invite 3 friends, get 1 month of Plus" on the invite settings page. -// Keep this off until the backend actually grants the free month on the third -// referral — with it off the page only describes referrals, it promises nothing. -export const featureReferralPlusReward = new Feature( - 'referral_plus_reward', - false, -); - export const featureGivebackSuggestCause = new Feature( 'giveback_suggest_cause', false, diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx index 09057781e2..e3054d8a7e 100644 --- a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; +import { screen, userEvent } from 'storybook/test'; import { addMonths, format } from 'date-fns'; import type { InviteRewardPeriod } from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; import { @@ -9,7 +10,11 @@ import { } from '@dailydotdev/shared/src/components/referral/InviteRewardProgress'; import { GivebackInviteCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackInviteCard'; import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; -import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; import UserList from '@dailydotdev/shared/src/components/profile/UserList'; import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; @@ -18,7 +23,6 @@ import { cloudinaryCharmInviteFriends } from '@dailydotdev/shared/src/lib/image' import { Typography, TypographyColor, - TypographyTag, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; @@ -27,12 +31,11 @@ import NotificationItem from '@dailydotdev/shared/src/components/notifications/N import { referralNotifications } from '../../components/notifications/_mock'; import { INVITE_LINK, + INVITE_TEXT, mockReferredUsers, withInvite, } from './inviteFriends.mocks'; -const noop = (): void => undefined; - // Stand-ins for the webapp settings chrome (AccountPageContainer / // AccountContentSection), so the real components can be judged in the layout // they actually ship in. The page body itself lives in the webapp package and @@ -76,23 +79,13 @@ interface InvitePageProps { joinedCount?: number; isPlus?: boolean; showGiveback?: boolean; - // Mirrors the `referral_plus_reward` flag on the real page. - showReward?: boolean; } // Mirrors packages/webapp/pages/settings/invite.tsx section for section. -const rewardDescription = ( - showReward: boolean, - isUnlocked: boolean, -): string => { - if (!showReward) { - return "Share daily.dev with developers you know. When they join through your link, they'll show up in your referrals below."; - } - - return isUnlocked +const rewardDescription = (isUnlocked: boolean): string => + isUnlocked ? 'Keep inviting. Every developer you bring makes the feed sharper.' : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; -}; // Same derivation as the page: the free month runs from the join date of the // INVITE_GOALth developer. @@ -114,7 +107,6 @@ const InvitePage = ({ joinedCount = 0, isPlus = false, showGiveback = true, - showReward = true, }: InvitePageProps): ReactElement => { const referredUsers = mockReferredUsers().slice(0, joinedCount); const isUnlocked = joinedCount >= INVITE_GOAL; @@ -123,14 +115,10 @@ const InvitePage = ({
- {showReward && isPlus && !isUnlocked && ( + {isPlus && !isUnlocked && ( )} - {showReward && ( - - )} +
+ } /> - - or invite via - -
- -
{showGiveback && (
( +
+ + } + /> +
+ ), + decorators: [withInvite()], + play: async () => { + await userEvent.click(await screen.findByLabelText('More ways to share')); + }, + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + 'Left half copies the link and swaps the glyph to a checkmark for a second. Right half opens the share list. Below laptop width the control collapses to a single button that opens the native share sheet, so there is no menu to open.', + }, + }, + }, +}; + export const GivebackCard: StoryObj = { name: 'Giveback cross-promo card', render: () => ( diff --git a/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx index ab3382bba8..5346a85218 100644 --- a/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx +++ b/packages/storybook/stories/features/referral/inviteFriends.mocks.tsx @@ -23,6 +23,9 @@ export const MOCK_USER = { export const INVITE_LINK = 'https://api.daily.dev/get?r=devdana'; +// Mirrors labels.referral.generic.inviteText, the text the real page shares. +export const INVITE_TEXT = `I'm using daily.dev to stay updated on developer news. I think you will find it helpful:`; + // The developers who joined through the link. Stories slice this list to get a // given progress state, so slot N is always the same person. export const mockReferredUsers = (): UserShortProfile[] => diff --git a/packages/webapp/__tests__/AccountInvitePage.tsx b/packages/webapp/__tests__/AccountInvitePage.tsx index 2ed1f8b97f..678b8e34c5 100644 --- a/packages/webapp/__tests__/AccountInvitePage.tsx +++ b/packages/webapp/__tests__/AccountInvitePage.tsx @@ -13,10 +13,7 @@ import { REFERRED_USERS_QUERY, } from '@dailydotdev/shared/src/graphql/users'; import type { Feature } from '@dailydotdev/shared/src/lib/featureManagement'; -import { - featureGiveback, - featureReferralPlusReward, -} from '@dailydotdev/shared/src/lib/featureManagement'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; @@ -142,20 +139,7 @@ const renderComponent = ( ); }; -it('should not promise the Plus reward while the flag is off', async () => { - mockedFeatures.set(featureReferralPlusReward, false); - mockReferralCampaign(2); - renderComponent(); - - expect(await screen.findByText('Grow the community')).toBeInTheDocument(); - expect( - screen.queryByText('Invite 3 friends, get 1 month of Plus'), - ).not.toBeInTheDocument(); - expect(screen.queryByText('2 of 3 friends joined')).not.toBeInTheDocument(); -}); - it('should render the 3-invites promo with the progress at zero', async () => { - mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(0); renderComponent(); @@ -167,7 +151,6 @@ it('should render the 3-invites promo with the progress at zero', async () => { }); it('should reflect partial progress from the referral campaign', async () => { - mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(2); renderComponent(); @@ -175,7 +158,6 @@ it('should reflect partial progress from the referral campaign', async () => { }); it('should show the unlocked state once three friends joined', async () => { - mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(3); renderComponent(); @@ -186,7 +168,6 @@ it('should show the unlocked state once three friends joined', async () => { }); it('should keep the unlocked copy count-agnostic beyond the goal', async () => { - mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(12); renderComponent(); @@ -198,7 +179,6 @@ it('should keep the unlocked copy count-agnostic beyond the goal', async () => { }); it('should run the free month from the join date of the third referral', async () => { - mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(3); renderComponent(loggedUser, [ // Deliberately out of order — the period is derived from the third @@ -213,7 +193,6 @@ it('should run the free month from the join date of the third referral', async ( }); it('should fill the progress slots with the referred users avatars', async () => { - mockedFeatures.set(featureReferralPlusReward, true); mockReferralCampaign(2); renderComponent(loggedUser, [ referredUser('ref-1', 'idakern'), diff --git a/packages/webapp/pages/settings/invite.tsx b/packages/webapp/pages/settings/invite.tsx index dea9ffee1a..e18e19101f 100644 --- a/packages/webapp/pages/settings/invite.tsx +++ b/packages/webapp/pages/settings/invite.tsx @@ -19,7 +19,6 @@ import UserList from '@dailydotdev/shared/src/components/profile/UserList'; import { checkFetchMore } from '@dailydotdev/shared/src/components/containers/InfiniteScrolling'; import type { ReferredUsersData } from '@dailydotdev/shared/src/graphql/common'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; import { addMonths, format } from 'date-fns'; @@ -29,24 +28,24 @@ import { TargetId, TargetType, } from '@dailydotdev/shared/src/lib/log'; -import type { ShareProvider } from '@dailydotdev/shared/src/lib/share'; -import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; +import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral/InviteLinkInput'; import { TruncateText } from '@dailydotdev/shared/src/components/utilities'; import type { NextSeoProps } from 'next-seo'; import { Typography, TypographyColor, - TypographyTag, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { usePlusSubscription } from '@dailydotdev/shared/src/hooks/usePlusSubscription'; import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; -import { - featureGiveback, - featureReferralPlusReward, -} from '@dailydotdev/shared/src/lib/featureManagement'; +import { featureGiveback } from '@dailydotdev/shared/src/lib/featureManagement'; import { INVITE_GOAL, InviteRewardProgress, @@ -73,22 +72,10 @@ const AccountInvitePage = (): ReactElement => { }); const { logEvent } = useLogContext(); const inviteLink = url || link.referral.defaultUrl; - const [, onShareOrCopyLink] = useShareOrCopyLink({ - text: labels.referral.generic.inviteText, - link: inviteLink, - logObject: () => ({ - event_name: LogEvent.CopyReferralLink, - target_id: TargetId.InviteFriendsPage, - }), - }); const { value: isGivebackEnabled } = useConditionalFeature({ feature: featureGiveback, shouldEvaluate: !!user, }); - const { value: isRewardEnabled } = useConditionalFeature({ - feature: featureReferralPlusReward, - shouldEvaluate: !!user, - }); const usersResult = useInfiniteQuery({ queryKey: referredKey, queryFn: ({ pageParam }) => @@ -135,7 +122,18 @@ const AccountInvitePage = (): ReactElement => { return { startsAt, endsAt: addMonths(startsAt, 1) }; }, [isRewardUnlocked, users]); - const onLogShare = (provider: ShareProvider) => { + // One handler for every route out of the split control: a copy keeps the + // page's existing copy event, anything else logs as a share with the provider. + const onShare = (provider: ShareProvider) => { + if (provider === ShareProvider.CopyLink) { + logEvent({ + event_name: LogEvent.CopyReferralLink, + target_id: TargetId.InviteFriendsPage, + }); + + return; + } + logEvent({ event_name: LogEvent.InviteReferral, target_id: provider, @@ -143,18 +141,11 @@ const AccountInvitePage = (): ReactElement => { }); }; - const getRewardDescription = () => { - if (!isRewardEnabled) { - return "Share daily.dev with developers you know. When they join through your link, they'll show up in your referrals below."; - } - - // The card below already announces the reward, so the unlocked copy only - // has to give a reason to keep going. - return isRewardUnlocked - ? 'Keep inviting. Every developer you bring makes the feed sharper.' - : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; - }; - const rewardDescription = getRewardDescription(); + // The card below already announces the reward, so the unlocked copy only has + // to give a reason to keep going. + const rewardDescription = isRewardUnlocked + ? 'Keep inviting. Every developer you bring makes the feed sharper.' + : `When ${INVITE_GOAL} developers join daily.dev through your link, your free month of Plus starts.`; const onGivebackClick = () => { logEvent({ @@ -167,14 +158,10 @@ const AccountInvitePage = (): ReactElement => { - {isRewardEnabled && isPlus && !isRewardUnlocked && ( + {isPlus && !isRewardUnlocked && ( { Already on Plus? The free month is added to your subscription. )} - {isRewardEnabled && ( - - )} + { event_name: LogEvent.CopyReferralLink, target_id: TargetId.InviteFriendsPage, }} + // The split control copies on the left half and drops the full share + // list on the right, so the field alone covers every share route. + actionButton={ + + } /> - - or invite via - -
- -
{isGivebackEnabled && ( Date: Thu, 23 Jul 2026 16:42:06 +0300 Subject: [PATCH 15/16] chore(referral): drop the launch config and the dead copy log props `.claude/launch.json` is local preview tooling and has nothing to do with the referral page, so it leaves the PR. `logProps` only drove InviteLinkInput's built-in copy button. The invite page replaces that button, so the prop was passed and never read; make it optional and stop passing it where it does nothing. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 21 ------------------- .../components/referral/InviteLinkInput.tsx | 9 ++++++-- .../referral/InviteFriends.stories.tsx | 9 -------- packages/webapp/pages/settings/invite.tsx | 4 ---- 4 files changed, 7 insertions(+), 36 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 3fb63337e5..0000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "storybook", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "--filter", - "storybook", - "exec", - "storybook", - "dev", - "-p", - "6013", - "--no-open" - ], - "port": 6013, - "autoPort": false - } - ] -} diff --git a/packages/shared/src/components/referral/InviteLinkInput.tsx b/packages/shared/src/components/referral/InviteLinkInput.tsx index 77abff4202..b00934aa13 100644 --- a/packages/shared/src/components/referral/InviteLinkInput.tsx +++ b/packages/shared/src/components/referral/InviteLinkInput.tsx @@ -18,7 +18,9 @@ interface InviteLinkInputProps { text?: Text; onCopy?: () => void; className?: FieldClassName; - logProps: LogEvent; + // Logged on copy. Only the built-in button copies, so surfaces that pass + // their own `actionButton` log from there instead. + logProps?: LogEvent; // Replaces the built-in copy button, for surfaces that need a richer control // (e.g. the split copy/share button). Optional so every other consumer keeps // its original button and copy handling. @@ -37,7 +39,10 @@ export function InviteLinkInput({ const { logEvent } = useLogContext(); const onCopyClick = () => { onCopyLink(); - logEvent(logProps); + + if (logProps) { + logEvent(logProps); + } if (onCopy) { onCopy(); diff --git a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx index e3054d8a7e..86235191b2 100644 --- a/packages/storybook/stories/features/referral/InviteFriends.stories.tsx +++ b/packages/storybook/stories/features/referral/InviteFriends.stories.tsx @@ -25,7 +25,6 @@ import { TypographyColor, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; -import { LogEvent, TargetId } from '@dailydotdev/shared/src/lib/log'; import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; import { referralNotifications } from '../../components/notifications/_mock'; @@ -138,10 +137,6 @@ const InvitePage = ({ { Date: Thu, 23 Jul 2026 17:04:11 +0300 Subject: [PATCH 16/16] refactor(share): align the split control with the share branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the reworked control from `claude/share-split-copy-button`: the geometry moves into a standalone `SplitShareButton`, the copy glyph swap into `CopyStateIcon`, and `ShareActions` composes them. The dropdown changes with it. The "Share" heading goes — the control it drops from is already labelled, so it only repeated it. The list is a 4-column grid rather than `flex-wrap`, so the columns divide the content box exactly and the padding reads equal on all four sides, and the padding needs `!p-4` to beat the dropdown's own `p-1.5` at equal specificity. The chevron loses `pressed`: it renders `aria-pressed`, which paints a held-down fill, and Radix already sets `aria-expanded`. The two local deltas ride along unchanged — `shortenUrl` for the referral link, and each half stopping its own click so the surrounding field cannot steal focus and dismiss the menu. Co-Authored-By: Claude Opus 4.8 --- .../src/components/share/CopyStateIcon.tsx | 47 ++++ .../src/components/share/ShareActions.tsx | 259 +++--------------- .../src/components/share/SplitShareButton.tsx | 195 +++++++++++++ 3 files changed, 282 insertions(+), 219 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx create mode 100644 packages/shared/src/components/share/SplitShareButton.tsx diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx new file mode 100644 index 0000000000..6c5defa913 --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.tsx @@ -0,0 +1,47 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { CopyIcon, VIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * easeOutExpo — the curve the design-system dropdown animates on. It + * decelerates into the target with no overshoot, which is what keeps a swap + * from reading as a wobble. + */ +export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; + +/** + * A copy is a rare, deliberate moment, so the confirmation earns real motion. + * Both glyphs share one grid cell so the label never shifts mid-swap, and the + * transition collapses to an instant swap under `prefers-reduced-motion`. + */ +export const CopyStateIcon = ({ + copied, + className, + ...props +}: IconProps & { copied: boolean }): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx index 8f27d09cdf..6ba3645b50 100644 --- a/packages/shared/src/components/share/ShareActions.tsx +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -5,14 +5,7 @@ import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; import { PopoverContent } from '../popover/Popover'; import { SocialShareList } from '../widgets/SocialShareList'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { ArrowIcon, CopyIcon, VIcon } from '../icons'; -import type { IconProps } from '../Icon'; -import { IconSize } from '../Icon'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '../dropdown/DropdownMenu'; +import { CopyIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import { Typography, TypographyType } from '../typography/Typography'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; @@ -20,6 +13,8 @@ import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; import { shouldUseNativeShare } from '../../lib/func'; import { ShareProvider } from '../../lib/share'; import type { ReferralCampaignKey } from '../../lib/referral'; +import { CopyStateIcon } from './CopyStateIcon'; +import { SplitShareButton } from './SplitShareButton'; export type ShareActionsVariant = 'icon' | 'inline' | 'split'; @@ -53,122 +48,6 @@ export interface ShareActionsProps { const HOVER_CLOSE_DELAY = 120; -/** - * easeOutExpo — the same curve the design-system dropdown animates on. It - * decelerates into the target with no overshoot, which is what keeps a swap - * from reading as a wobble. - */ -const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; - -/** - * The halves meet at a single hairline, so the standard side paddings would - * leave a canyon around it. Both sides tighten by one step from the DS value — - * the outer edges keep the standard padding, so the control still reads as one - * button. - */ -const SPLIT_MAIN_INNER_PADDING: Record = { - [ButtonSize.XLarge]: '!pr-5', - [ButtonSize.Large]: '!pr-4', - [ButtonSize.Medium]: '!pr-3', - [ButtonSize.Small]: '!pr-2', - [ButtonSize.XSmall]: '!pr-1.5', -}; - -const DIVIDER_BASE = - "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; - -/** - * Variants that paint `--button-default-border-color: transparent` have no - * border for the divider to match, so they draw their own 1px rule: - * - * - `Primary` sits on a solid fill, where only the label colour is guaranteed - * to read, and runs full height like a real border would. - * - `Tertiary` is a bare ghost button, so a full-height rule floats with - * nothing to anchor it. It gets a shorter rule in the same colour the - * `Subtle` variant borders with — `border-subtlest-primary` at 30 %, per - * `tailwind/buttons.ts`. - * - * Every other variant returns nothing and keeps its real border as the divider. - */ -const dividerFor = (buttonVariant: ButtonVariant): string | false => { - if (buttonVariant === ButtonVariant.Primary) { - return classNames( - DIVIDER_BASE, - // Alpha is mixed into the colour rather than applied as a separate - // opacity utility: `before:opacity-*` does not survive this project's - // Tailwind build on pseudo-elements, and silently renders at full - // strength. - 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', - ); - } - - if (buttonVariant === ButtonVariant.Tertiary) { - return classNames( - DIVIDER_BASE, - // The literal expression `tailwind/buttons.ts` gives the Subtle variant's - // border, rather than a token plus an opacity utility that only - // approximates it. - 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', - ); - } - - return false; -}; - -/** - * The split halves keep their clicks to themselves: a parent that acts on - * clicks steals focus the moment the menu opens, which dismisses it right away - * (e.g. a TextField, whose container focuses its input on click). - */ -const stopParentClick = (event: React.MouseEvent): void => - event.stopPropagation(); - -/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ -const SPLIT_CHEVRON_PADDING: Record = { - [ButtonSize.XLarge]: '!w-auto !px-3', - [ButtonSize.Large]: '!w-auto !px-2.5', - [ButtonSize.Medium]: '!w-auto !px-2', - [ButtonSize.Small]: '!w-auto !px-1.5', - [ButtonSize.XSmall]: '!w-auto !px-1', -}; - -/** - * A copy is a rare, deliberate moment, so the confirmation earns real motion: - * the two glyphs cross-fade in place on opacity + scale + blur (all - * compositor-only) rather than snapping. They share one grid cell so the label - * never shifts mid-swap, and the whole thing collapses to an instant swap under - * `prefers-reduced-motion`. - */ -const CopyStateIcon = ({ - copied, - className, - ...props -}: IconProps & { copied: boolean }): ReactElement => { - const layer = classNames( - className, - 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', - EASE_OUT_EXPO, - ); - - return ( - - - - - ); -}; - export function ShareActions({ link, text, @@ -191,9 +70,21 @@ export function ShareActions({ const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); const closeTimeout = useRef>(); + const onCopy = () => { + onShare?.(ShareProvider.CopyLink); + shareOrCopy(); + }; + // `copying` stays true for a second after a copy, which is the whole window - // for the confirmation swap. - const copyIcon = ; + // for the confirmation. The green-check swap is scoped to the split control — + // icon-only triggers keep the existing `secondary` fill so this does not + // restyle every share surface in the app. + const copyIcon = + variant === 'split' ? ( + + ) : ( + + ); const list = ( { - onShare?.(ShareProvider.CopyLink); - shareOrCopy(); - }} + onCopy={onCopy} onNativeShare={() => { onShare?.(ShareProvider.Native); shareOrCopy(); @@ -250,6 +138,24 @@ export function ShareActions({ ); } + if (variant === 'split') { + return ( + + ); + } + const cancelClose = () => { if (closeTimeout.current) { clearTimeout(closeTimeout.current); @@ -270,94 +176,6 @@ export function ShareActions({ } : undefined; - const shareList = ( - <> - - Share - - {list} - - ); - - // Two real buttons that read as one control: the left half copies straight - // away, the right half drops the standard share menu. - if (variant === 'split') { - return ( -
- - - - - -
- ); - } - return ( @@ -383,7 +201,10 @@ export function ShareActions({ className="flex w-80 flex-wrap justify-center gap-2 rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4 shadow-2 data-[side=bottom]:mt-1 data-[side=top]:mb-1" {...hoverProps} > - {shareList} + + Share + + {list} ); diff --git a/packages/shared/src/components/share/SplitShareButton.tsx b/packages/shared/src/components/share/SplitShareButton.tsx new file mode 100644 index 0000000000..ca76c9e7e0 --- /dev/null +++ b/packages/shared/src/components/share/SplitShareButton.tsx @@ -0,0 +1,195 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { Tooltip } from '../tooltip/Tooltip'; +import { CopyStateIcon, EASE_OUT_EXPO } from './CopyStateIcon'; + +export interface SplitShareButtonProps { + /** Tooltip and accessible name for the copy half. */ + label: string; + /** Accessible name for the chevron half. */ + dropdownLabel: string; + /** Visible text on the copy half; falls back to `label`. */ + triggerText?: string; + /** Contents of the dropdown the chevron opens. */ + menu: ReactNode; + /** Drives the copy-glyph confirmation swap. */ + copied: boolean; + onCopy: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} + +/** + * The halves meet at a single hairline, so the standard side paddings would + * leave a canyon around it. Both sides tighten by one step from the DS value — + * the outer edges keep the standard padding, so the control still reads as one + * button. + */ +const MAIN_INNER_PADDING: Record = { + [ButtonSize.XLarge]: '!pr-5', + [ButtonSize.Large]: '!pr-4', + [ButtonSize.Medium]: '!pr-3', + [ButtonSize.Small]: '!pr-2', + [ButtonSize.XSmall]: '!pr-1.5', +}; + +/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ +const CHEVRON_PADDING: Record = { + [ButtonSize.XLarge]: '!w-auto !px-3', + [ButtonSize.Large]: '!w-auto !px-2.5', + [ButtonSize.Medium]: '!w-auto !px-2', + [ButtonSize.Small]: '!w-auto !px-1.5', + [ButtonSize.XSmall]: '!w-auto !px-1', +}; + +const DIVIDER_BASE = + "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; + +/** + * Variants that paint `--button-default-border-color: transparent` have no + * border for the divider to match, so they draw their own 1px rule. Every other + * variant returns nothing and keeps its real border as the divider. + * + * Alpha is mixed into the colour rather than applied as a separate utility: + * `before:opacity-*` does not survive this project's Tailwind build on + * pseudo-elements and silently renders at full strength. `bg-current` is + * likewise unusable — the theme replaces Tailwind's `colors` wholesale and has + * no `current` key, so it compiles to nothing at all. + */ +const dividerFor = (variant: ButtonVariant): string | false => { + // Sits on a solid fill, where only the label colour is guaranteed to read. + if (variant === ButtonVariant.Primary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', + ); + } + + // A bare ghost button: a full-height rule would float with nothing to anchor + // it, so it gets a shorter one in the colour `tailwind/buttons.ts` gives the + // Subtle variant's border. + if (variant === ButtonVariant.Tertiary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', + ); + } + + return false; +}; + +/** + * The halves keep their clicks to themselves: a parent that acts on clicks + * steals focus the moment the menu opens, which dismisses it right away — e.g. + * a TextField, whose container focuses its input on click. + */ +const stopParentClick = (event: React.MouseEvent): void => + event.stopPropagation(); + +/** + * Two real buttons that read as one control: the left half runs the primary + * action, the right half drops the standard menu. Geometry matches a standard + * button at every size — only the shared edge deviates. + */ +export const SplitShareButton = ({ + label, + dropdownLabel, + triggerText, + menu, + copied, + onCopy, + variant = ButtonVariant.Tertiary, + size = ButtonSize.Small, + className, +}: SplitShareButtonProps): ReactElement => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + +
+ ); +};