From f0ed299944a67b688b319f9bf76079cc7fc371a1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 19:29:39 +0300 Subject: [PATCH] feat(onboarding): invite 3 friends, get free Plus funnel step Adds a FunnelStepType.InviteFriends onboarding step that fires referral intent at the highest-motivation moment: invite friends via the generic referral campaign, with a 0/3 progress indicator from referredUsersCount and a confetti celebration when the target is reached. Skip is always available, the reward copy ({reward} placeholder, default "1 month") is A/B-able straight from the funnel JSON, and the whole step is gated behind the new onboarding_invite_reward flag (default false). Frontend-only: the step needs the backend funnel JSON to include it and a backend Plus-grant mechanism to award the reward; no Plus state is ever set client-side. Co-Authored-By: Claude Fable 5 --- .../onboarding/shared/FunnelStepper.spec.tsx | 88 ++++- .../onboarding/shared/FunnelStepper.tsx | 2 + .../steps/FunnelInviteFriends.spec.tsx | 306 ++++++++++++++++ .../onboarding/steps/FunnelInviteFriends.tsx | 328 ++++++++++++++++++ .../src/features/onboarding/steps/index.ts | 1 + .../src/features/onboarding/types/funnel.ts | 30 +- .../features/onboarding/types/funnelEvents.ts | 4 + packages/shared/src/lib/featureManagement.ts | 9 + .../FunnelInviteFriends.stories.tsx | 200 +++++++++++ 9 files changed, 965 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/features/onboarding/steps/FunnelInviteFriends.spec.tsx create mode 100644 packages/shared/src/features/onboarding/steps/FunnelInviteFriends.tsx create mode 100644 packages/storybook/stories/components/onboarding/FunnelInviteFriends.stories.tsx diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx index 446a3d503ec..9e389d16ce0 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; import { FunnelStepper } from './FunnelStepper'; import { useFunnelNavigation } from '../hooks/useFunnelNavigation'; import { useFunnelTracking } from '../hooks/useFunnelTracking'; @@ -15,6 +16,8 @@ import { import type { FunnelJSON, FunnelStep, FunnelStepQuiz } from '../types/funnel'; import { waitForNock } from '../../../../__tests__/helpers/utilities'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import loggedUser from '../../../../__tests__/fixture/loggedUser'; +import type { AuthContextData } from '../../../contexts/AuthContext'; import { StepHeadlineAlign } from './StepHeadline'; import type { FunnelSession } from '../types/funnelBoot'; @@ -28,6 +31,26 @@ jest.mock('../hooks/useFunnelNavigation', () => { }); jest.mock('../hooks/useFunnelTracking'); jest.mock('../hooks/useStepTransition'); +// The invite friends step reads the referral campaign; keep it static so the +// stepper tests exercise wiring, not data fetching. +jest.mock('../../../hooks/referral/useReferralCampaign', () => { + const actual = jest.requireActual( + '../../../hooks/referral/useReferralCampaign', + ); + return { + ...actual, + useReferralCampaign: jest.fn(() => ({ + url: 'https://dly.to/invite123', + referredUsersCount: 0, + referralCountLimit: 5, + referralToken: 'token123', + isReady: true, + isCompleted: false, + availableCount: 5, + noKeysAvailable: false, + })), + }; +}); let client: QueryClient; @@ -120,9 +143,12 @@ describe('FunnelStepper component', () => { }); }); - const renderComponent = (funnel = mockFunnel) => { + const renderComponent = ( + funnel = mockFunnel, + options: { auth?: Partial; gb?: GrowthBook } = {}, + ) => { return render( - + { inputs: { step1: 'Option 1' }, }); }); + + describe('invite friends step', () => { + const inviteStep = { + id: 'invite-friends', + type: FunnelStepType.InviteFriends, + parameters: {}, + transitions: [ + { + on: FunnelStepTransitionType.Complete, + destination: COMPLETED_STEP_ID, + }, + { on: FunnelStepTransitionType.Skip, destination: COMPLETED_STEP_ID }, + ], + } as unknown as FunnelStep; + + const inviteFunnel: FunnelJSON = { + id: 'test-funnel', + version: 1, + parameters: {}, + entryPoint: 'invite-friends', + chapters: [{ id: 'chapter1', steps: [inviteStep] }], + }; + + beforeEach(() => { + (useFunnelNavigation as jest.Mock).mockReturnValue({ + back: mockBack, + skip: mockSkip, + navigate: mockNavigate, + position: { chapter: 0, step: 0 }, + chapters: [{ steps: 1 }], + step: inviteStep, + stepMap: { 'invite-friends': { position: { chapter: 0, step: 0 } } }, + isReady: true, + }); + }); + + it('should render the registered step when the flag is on', () => { + renderComponent(inviteFunnel, { + auth: { user: loggedUser }, + gb: new GrowthBook({ + features: { onboarding_invite_reward: { defaultValue: true } }, + }), + }); + + expect( + screen.getByText('Invite 3 friends, get 1 month of Plus on us'), + ).toBeInTheDocument(); + }); + + it('should render nothing for the step when the flag is off', () => { + renderComponent(inviteFunnel, { auth: { user: loggedUser } }); + + expect(screen.getByTestId('funnel-stepper')).toBeInTheDocument(); + expect( + screen.queryByText('Invite 3 friends, get 1 month of Plus on us'), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 660895b5420..ea58d12148e 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -35,6 +35,7 @@ import { FunnelHeroLanding, FunnelBrowserExtension, FunnelUploadCv, + FunnelInviteFriends, } from '../steps'; import { FunnelFact } from '../steps/FunnelFact'; import { FunnelCheckout } from '../steps/FunnelCheckout'; @@ -81,6 +82,7 @@ const stepComponentMap = { [FunnelStepType.PlusCards]: FunnelPlusCards, [FunnelStepType.BrowserExtension]: FunnelBrowserExtension, [FunnelStepType.UploadCv]: FunnelUploadCv, + [FunnelStepType.InviteFriends]: FunnelInviteFriends, } as const; function FunnelStepComponent(props: { diff --git a/packages/shared/src/features/onboarding/steps/FunnelInviteFriends.spec.tsx b/packages/shared/src/features/onboarding/steps/FunnelInviteFriends.spec.tsx new file mode 100644 index 00000000000..f259e3129fd --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelInviteFriends.spec.tsx @@ -0,0 +1,306 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { FunnelInviteFriends } from './FunnelInviteFriends'; +import type { FunnelStepInviteFriends } from '../types/funnel'; +import { + COMPLETED_STEP_ID, + FunnelStepTransitionType, + FunnelStepType, +} from '../types/funnel'; +import { FunnelTargetId } from '../types/funnelEvents'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import user from '../../../../__tests__/fixture/loggedUser'; +import type { AuthContextData } from '../../../contexts/AuthContext'; +import { useReferralCampaign } from '../../../hooks/referral/useReferralCampaign'; +import { shouldUseNativeShare } from '../../../lib/func'; + +jest.mock('../../../hooks/referral/useReferralCampaign', () => { + const actual = jest.requireActual( + '../../../hooks/referral/useReferralCampaign', + ); + return { + ...actual, + useReferralCampaign: jest.fn(), + }; +}); + +jest.mock('../../../lib/func', () => ({ + ...jest.requireActual('../../../lib/func'), + shouldUseNativeShare: jest.fn(() => false), +})); + +const mockDisplayToast = jest.fn(); +jest.mock('../../../hooks/useToastNotification', () => ({ + ...jest.requireActual('../../../hooks/useToastNotification'), + useToastNotification: () => ({ + displayToast: mockDisplayToast, + dismissToast: jest.fn(), + }), +})); + +const inviteUrl = 'https://dly.to/invite123'; +const mockOnTransition = jest.fn(); +const mockOnRegisterStepToSkip = jest.fn(); + +const flagOnGb = () => + new GrowthBook({ + features: { onboarding_invite_reward: { defaultValue: true } }, + }); + +const mockReferralCampaign = (referredUsersCount: number) => { + (useReferralCampaign as jest.Mock).mockReturnValue({ + url: inviteUrl, + referredUsersCount, + referralCountLimit: 5, + referralToken: 'token123', + isReady: true, + isCompleted: false, + availableCount: 5 - referredUsersCount, + noKeysAvailable: false, + }); +}; + +const defaultProps: FunnelStepInviteFriends = { + id: 'invite-friends', + type: FunnelStepType.InviteFriends, + parameters: {}, + transitions: [ + { + on: FunnelStepTransitionType.Complete, + destination: COMPLETED_STEP_ID, + }, + { + on: FunnelStepTransitionType.Skip, + destination: COMPLETED_STEP_ID, + }, + ], + isActive: true, + onTransition: mockOnTransition, + onRegisterStepToSkip: mockOnRegisterStepToSkip, +}; + +const renderComponent = ( + props: Partial = {}, + { + gb = flagOnGb(), + auth = { user }, + }: { gb?: GrowthBook; auth?: Partial } = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +}; + +describe('FunnelInviteFriends', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockReferralCampaign(0); + (shouldUseNativeShare as jest.Mock).mockReturnValue(false); + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: { writeText: jest.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + }); + + it('should render the default headline with progress from referredUsersCount', () => { + renderComponent(); + + expect( + screen.getByText('Invite 3 friends, get 1 month of Plus on us'), + ).toBeInTheDocument(); + expect(screen.getByText('0/3 friends joined')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '0', + ); + expect(screen.getByDisplayValue(inviteUrl)).toBeInTheDocument(); + }); + + it('should reflect partial progress', () => { + mockReferralCampaign(1); + renderComponent(); + + expect(screen.getByText('1/3 friends joined')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '1', + ); + }); + + it('should support A/B-able reward copy via the reward parameter', () => { + renderComponent({ parameters: { reward: '3 months' } }); + + expect( + screen.getByText('Invite 3 friends, get 3 months of Plus on us'), + ).toBeInTheDocument(); + }); + + it('should copy the invite link and toast on the primary CTA (desktop)', async () => { + renderComponent(); + + const ctaButton = screen.getByRole('button', { name: 'Copy invite link' }); + expect(ctaButton).toHaveAttribute( + 'data-funnel-track', + FunnelTargetId.InviteFriends, + ); + fireEvent.click(ctaButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(inviteUrl); + }); + expect(mockDisplayToast).toHaveBeenCalled(); + expect(mockOnTransition).not.toHaveBeenCalled(); + + // After inviting, the CTA becomes Continue and completes the step. + const continueButton = await screen.findByRole('button', { + name: 'Continue', + }); + fireEvent.click(continueButton); + expect(mockOnTransition).toHaveBeenCalledWith({ + type: FunnelStepTransitionType.Complete, + details: { + referredUsersCount: 0, + targetReached: false, + invited: true, + }, + }); + }); + + it('should open the native share sheet on mobile', async () => { + (shouldUseNativeShare as jest.Mock).mockReturnValue(true); + const mockShare = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(globalThis.navigator, 'share', { + value: mockShare, + configurable: true, + }); + + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Invite friends' })); + + await waitFor(() => { + expect(mockShare).toHaveBeenCalledWith({ + text: expect.stringContaining(inviteUrl), + }); + }); + expect(mockOnTransition).not.toHaveBeenCalled(); + }); + + it('should skip the step without requiring an invite', () => { + renderComponent(); + + const skipButton = screen.getByRole('button', { + name: "I'll do this later", + }); + expect(skipButton).toHaveAttribute( + 'data-funnel-track', + FunnelTargetId.InviteFriendsSkip, + ); + expect(skipButton).toHaveAttribute('type', 'button'); + fireEvent.click(skipButton); + + expect(mockOnTransition).toHaveBeenCalledWith({ + type: FunnelStepTransitionType.Skip, + details: { + referredUsersCount: 0, + targetReached: false, + invited: false, + }, + }); + }); + + it('should use the skip copy from the funnel transition when provided', () => { + renderComponent({ + transitions: [ + { + on: FunnelStepTransitionType.Complete, + destination: COMPLETED_STEP_ID, + }, + { + on: FunnelStepTransitionType.Skip, + destination: COMPLETED_STEP_ID, + cta: 'Not now', + }, + ], + }); + + expect(screen.getByRole('button', { name: 'Not now' })).toBeInTheDocument(); + }); + + it('should tag copy and share groups with funnel target ids', () => { + renderComponent(); + + expect( + screen.getByRole('group', { name: 'Copy your invite link' }), + ).toHaveAttribute('data-funnel-track', FunnelTargetId.InviteFriendsCopy); + expect( + screen.getByRole('group', { name: 'Invite via social platforms' }), + ).toHaveAttribute('data-funnel-track', FunnelTargetId.InviteFriendsShare); + }); + + it('should celebrate at target and complete the step from the celebration CTA', () => { + mockReferralCampaign(3); + renderComponent(); + + expect( + screen.getByText('3 friends joined. Plus is on us'), + ).toBeInTheDocument(); + expect(screen.getByText('3/3 friends joined')).toBeInTheDocument(); + // Invite + skip affordances are gone once the target is reached. + expect(screen.queryByDisplayValue(inviteUrl)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: "I'll do this later" }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(mockOnTransition).toHaveBeenCalledWith({ + type: FunnelStepTransitionType.Complete, + details: { + referredUsersCount: 3, + targetReached: true, + invited: false, + }, + }); + }); + + it('should register itself to be skipped when the flag is off', () => { + renderComponent({}, { gb: new GrowthBook() }); + + expect(mockOnRegisterStepToSkip).toHaveBeenCalledWith( + FunnelStepType.InviteFriends, + true, + ); + expect( + screen.queryByText('Invite 3 friends, get 1 month of Plus on us'), + ).not.toBeInTheDocument(); + }); + + it('should register itself to be skipped for anonymous users', () => { + renderComponent({}, { auth: { user: undefined } }); + + expect(mockOnRegisterStepToSkip).toHaveBeenCalledWith( + FunnelStepType.InviteFriends, + true, + ); + expect( + screen.queryByText('Invite 3 friends, get 1 month of Plus on us'), + ).not.toBeInTheDocument(); + }); + + it('should register itself as not skipped when the flag is on', () => { + renderComponent(); + + expect(mockOnRegisterStepToSkip).toHaveBeenCalledWith( + FunnelStepType.InviteFriends, + false, + ); + }); +}); diff --git a/packages/shared/src/features/onboarding/steps/FunnelInviteFriends.tsx b/packages/shared/src/features/onboarding/steps/FunnelInviteFriends.tsx new file mode 100644 index 00000000000..2371b2cbb75 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelInviteFriends.tsx @@ -0,0 +1,328 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import type { FunnelStepInviteFriends } from '../types/funnel'; +import { FunnelStepTransitionType } from '../types/funnel'; +import { FunnelTargetId } from '../types/funnelEvents'; +import { withIsActiveGuard } from '../shared/withActiveGuard'; +import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; +import { StepHeadline } from '../shared/StepHeadline'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureOnboardingInviteReward } from '../../../lib/featureManagement'; +import { + ReferralCampaignKey, + useReferralCampaign, +} from '../../../hooks/referral/useReferralCampaign'; +import { useShareOrCopyLink } from '../../../hooks/useShareOrCopyLink'; +import { InviteLinkInput } from '../../../components/referral/InviteLinkInput'; +import { SocialShareList } from '../../../components/widgets/SocialShareList'; +import { GivebackConfettiBurst } from '../../giveback/components/GivebackConfettiBurst'; +import { labels } from '../../../lib/labels'; +import { link } from '../../../lib/links'; +import { LogEvent, TargetId } from '../../../lib/log'; +import type { ShareProvider } from '../../../lib/share'; +import { shouldUseNativeShare } from '../../../lib/func'; +import { UserIcon, VIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; + +// Copy defaults live here (not in the funnel JSON) so the step renders a +// complete experience even before Freyja overrides land. `{count}` and +// `{reward}` placeholders keep the reward framing A/B-able from the JSON. +const INVITE_FRIENDS_DEFAULTS = { + headline: 'Invite {count} friends, get {reward} of Plus on us', + explainer: + 'You help daily.dev grow, we return the favor. When {count} developers join with your personal link, you unlock daily.dev Plus free for {reward}.', + celebrationHeadline: '{count} friends joined. Plus is on us', + celebrationExplainer: + 'Your invites made it. Your free {reward} of daily.dev Plus will be applied to your account.', + celebrationCta: 'Continue', + continueCta: 'Continue', + skipCta: "I'll do this later", + reward: '1 month', + targetCount: 3, +}; + +const applyCopyPlaceholders = ( + copy: string, + targetCount: number, + reward: string, +): string => + copy + .replace(/\{count\}/g, String(targetCount)) + .replace(/\{reward\}/g, reward); + +// Exported unguarded for Storybook, which can't provide the GrowthBook +// context the flag guard needs; production always goes through the guarded +// `FunnelInviteFriends` below. +export const InviteFriendsStep = ({ + parameters, + transitions, + onTransition, +}: FunnelStepInviteFriends): ReactElement => { + const { + headline = INVITE_FRIENDS_DEFAULTS.headline, + explainer = INVITE_FRIENDS_DEFAULTS.explainer, + cta, + celebrationHeadline = INVITE_FRIENDS_DEFAULTS.celebrationHeadline, + celebrationExplainer = INVITE_FRIENDS_DEFAULTS.celebrationExplainer, + celebrationCta = INVITE_FRIENDS_DEFAULTS.celebrationCta, + reward = INVITE_FRIENDS_DEFAULTS.reward, + targetCount = INVITE_FRIENDS_DEFAULTS.targetCount, + } = parameters; + const { logEvent } = useLogContext(); + const { url, referredUsersCount } = useReferralCampaign({ + campaignKey: ReferralCampaignKey.Generic, + }); + const inviteLink = url || link.referral.defaultUrl; + const joinedCount = Math.min(referredUsersCount, targetCount); + const isTargetReached = joinedCount >= targetCount; + // Whether the user took any invite action this session; flips the primary + // CTA to "Continue" so inviting never dead-ends the funnel. + const [hasInvited, setHasInvited] = useState(false); + const [celebrationBurst, setCelebrationBurst] = useState(0); + const skipTransition = useMemo( + () => transitions.find((t) => t.on === FunnelStepTransitionType.Skip), + [transitions], + ); + const [, onShareOrCopyLink] = useShareOrCopyLink({ + text: labels.referral.generic.inviteText, + link: inviteLink, + logObject: () => ({ + event_name: LogEvent.CopyReferralLink, + target_id: TargetId.Onboarding, + }), + }); + + useEffect(() => { + if (isTargetReached) { + setCelebrationBurst((count) => count + 1); + } + }, [isTargetReached]); + + const transitionDetails = { + referredUsersCount, + targetReached: isTargetReached, + invited: hasInvited, + }; + + const onInvite = () => { + setHasInvited(true); + onShareOrCopyLink(); + }; + + const onSocialShare = (provider: ShareProvider) => { + setHasInvited(true); + logEvent({ + event_name: LogEvent.InviteReferral, + target_id: provider, + target_type: TargetId.Onboarding, + }); + }; + + const isContinueMode = isTargetReached || hasInvited; + const primaryCtaLabel = (() => { + if (isTargetReached) { + return celebrationCta; + } + if (hasInvited) { + return INVITE_FRIENDS_DEFAULTS.continueCta; + } + return ( + cta ?? (shouldUseNativeShare() ? 'Invite friends' : 'Copy invite link') + ); + })(); + + const progressIndicator = ( +
+
+ {Array.from({ length: targetCount }, (_, index) => { + const isJoined = index < joinedCount; + return ( + + {isJoined ? ( + + ) : ( + + )} + + ); + })} +
+ + {joinedCount}/{targetCount} friends joined + +
+ ); + + return ( +
+
+ {isTargetReached ? ( +
+ + + {progressIndicator} +
+ ) : ( + <> + + {progressIndicator} +
+ setHasInvited(true)} + /> +
+ + or invite via + +
+ +
+ + )} +
+
+ + {!!skipTransition && !isTargetReached && ( + + )} +
+
+ ); +}; + +export const FunnelInviteFriends = withShouldSkipStepGuard( + withIsActiveGuard(InviteFriendsStep), + () => { + const { user } = useAuthContext(); + // Referral links are per-user: an anonymous visitor can't attribute + // invites, so the step only makes sense after registration. + const hasUser = !!user?.id; + const { value: isFlagEnabled } = useConditionalFeature({ + feature: featureOnboardingInviteReward, + // This hook only mounts when the funnel JSON contains the step, so + // evaluating here enrolls exactly the cohort that could see it. + shouldEvaluate: hasUser, + }); + + return { shouldSkip: !hasUser || !isFlagEnabled }; + }, +); + +export default FunnelInviteFriends; diff --git a/packages/shared/src/features/onboarding/steps/index.ts b/packages/shared/src/features/onboarding/steps/index.ts index 5352bf46933..15f8e5a5c6f 100644 --- a/packages/shared/src/features/onboarding/steps/index.ts +++ b/packages/shared/src/features/onboarding/steps/index.ts @@ -13,3 +13,4 @@ export { FunnelPlusCards } from './FunnelPlusCards'; export { FunnelOrganicCheckout } from './FunnelOrganicCheckout'; export { FunnelBrowserExtension } from './FunnelBrowserExtension'; export { FunnelUploadCv } from './FunnelUploadCv'; +export { FunnelInviteFriends } from './FunnelInviteFriends'; diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts index b7a64c065b9..50352ef6fa1 100644 --- a/packages/shared/src/features/onboarding/types/funnel.ts +++ b/packages/shared/src/features/onboarding/types/funnel.ts @@ -36,6 +36,7 @@ export enum FunnelStepType { HeroLanding = 'heroLanding', BrowserExtension = 'browserExtension', UploadCv = 'uploadCv', + InviteFriends = 'inviteFriends', } export enum FunnelBackgroundVariant { @@ -415,6 +416,32 @@ export interface FunnelStepUploadCv onTransition: FunnelStepTransitionCallback; } +export interface FunnelStepInviteFriends + extends FunnelStepCommon<{ + headline?: string; + explainer?: string; + cta?: string; + celebrationHeadline?: string; + celebrationExplainer?: string; + celebrationCta?: string; + /** + * Reward framing injected into `{reward}` copy placeholders (e.g. + * "1 month" vs "3 months"). Copy-only on the frontend so the reward can be + * A/B-tested straight from the funnel JSON; the actual Plus grant is a + * backend concern. + */ + reward?: string; + /** Successful referrals needed to unlock the reward. */ + targetCount?: number; + }> { + type: FunnelStepType.InviteFriends; + onTransition: FunnelStepTransitionCallback<{ + referredUsersCount: number; + targetReached: boolean; + invited: boolean; + }>; +} + export type FunnelStep = | FunnelStepLandingPage | FunnelStepFact @@ -436,7 +463,8 @@ export type FunnelStep = | FunnelStepHeroLanding | FunnelStepBrowserExtension | FunnelStepPlusCards - | FunnelStepUploadCv; + | FunnelStepUploadCv + | FunnelStepInviteFriends; export type FunnelPosition = { chapter: number; diff --git a/packages/shared/src/features/onboarding/types/funnelEvents.ts b/packages/shared/src/features/onboarding/types/funnelEvents.ts index 395c10590f1..29201504765 100644 --- a/packages/shared/src/features/onboarding/types/funnelEvents.ts +++ b/packages/shared/src/features/onboarding/types/funnelEvents.ts @@ -27,6 +27,10 @@ export enum FunnelTargetId { FeedTag = 'feed tag', FeedPreview = 'feed preview', FeedContentType = 'feed content type', + InviteFriends = 'invite friends', + InviteFriendsCopy = 'invite friends copy', + InviteFriendsShare = 'invite friends share', + InviteFriendsSkip = 'invite friends skip', } export type FunnelEvent = diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..a5bb7177f22 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,12 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Onboarding experiment: "Invite 3 friends, get a month of Plus on us" funnel +// step. The step can only appear when the backend funnel JSON includes an +// `inviteFriends` step; this flag is the frontend kill-switch on top and the +// experiment enrollment point. Keep the default `false` — GrowthBook ramps it. +export const featureOnboardingInviteReward = new Feature( + 'onboarding_invite_reward', + false, +); diff --git a/packages/storybook/stories/components/onboarding/FunnelInviteFriends.stories.tsx b/packages/storybook/stories/components/onboarding/FunnelInviteFriends.stories.tsx new file mode 100644 index 00000000000..cc70af0a5af --- /dev/null +++ b/packages/storybook/stories/components/onboarding/FunnelInviteFriends.stories.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import { InviteFriendsStep } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelInviteFriends'; +import type { FunnelStepInviteFriends } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { + COMPLETED_STEP_ID, + FunnelStepTransitionType, + FunnelStepType, +} from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { FunnelStepBackground } from '@dailydotdev/shared/src/features/onboarding/shared'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; + +const mockUser = { + id: '1', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + createdAt: '2024-01-01T00:00:00.000Z', + permalink: 'https://daily.dev/testuser', +} as unknown as LoggedUser; + +const LogContext = getLogContextStatic(); + +// Renders the unguarded step: the production `FunnelInviteFriends` export is +// additionally gated on the `onboarding_invite_reward` flag + a logged-in +// user (verified in Jest), which Storybook can't provide deterministically. +const StepProviders = ({ + children, + referredUsersCount, +}: { + children: React.ReactNode; + referredUsersCount: number; +}) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Seed the generic referral campaign so `useReferralCampaign` resolves + // without network access. + queryClient.setQueryData( + generateQueryKey(RequestKey.ReferralCampaigns, mockUser, { + referralOrigin: ReferralCampaignKey.Generic, + }), + { + referredUsersCount, + referralCountLimit: 5, + referralToken: 'storybook-token', + url: 'https://dly.to/storybook', + }, + ); + // Mock the short-URL resolution so copy/social actions don't hit network. + queryClient.setQueryData(['shortUrl'], 'https://dly.to/storybook'); + + return ( + + + false, + }} + > + {children} + + + + ); +}; + +interface StoryArgs extends FunnelStepInviteFriends { + referredUsersCount: number; +} + +const meta: Meta = { + title: 'Components/Onboarding/Steps/InviteFriends', + component: InviteFriendsStep, + parameters: { + controls: { + expanded: true, + }, + layout: 'fullscreen', + }, + argTypes: { + referredUsersCount: { + control: { type: 'number', min: 0, max: 5 }, + description: + 'Mocked `referredUsersCount` from the generic referral campaign; 3+ shows the celebration state', + }, + }, + tags: ['autodocs'], + render: ({ referredUsersCount, ...props }) => ( + + +
+ +
+
+
+ ), +}; + +export default meta; + +type Story = StoryObj; + +const commonProps: FunnelStepInviteFriends = { + id: 'invite-friends', + type: FunnelStepType.InviteFriends, + parameters: {}, + transitions: [ + { + on: FunnelStepTransitionType.Complete, + destination: COMPLETED_STEP_ID, + }, + { + on: FunnelStepTransitionType.Skip, + destination: COMPLETED_STEP_ID, + }, + ], + isActive: true, + onTransition: fn(), +}; + +export const ZeroJoined: Story = { + args: { + ...commonProps, + referredUsersCount: 0, + }, +}; + +export const OneJoined: Story = { + args: { + ...commonProps, + referredUsersCount: 1, + }, +}; + +export const TargetReachedCelebration: Story = { + args: { + ...commonProps, + referredUsersCount: 3, + }, +}; + +// The reward framing is a step parameter so it can be A/B-tested straight from +// the backend funnel JSON — this is the "3 months" arm. +export const RewardThreeMonths: Story = { + args: { + ...commonProps, + referredUsersCount: 0, + parameters: { + reward: '3 months', + }, + }, +}; + +// Without a `skip` transition in the funnel JSON the in-step skip button is +// hidden — the backend contract requires funnels to always define one. +export const WithoutSkipTransition: Story = { + args: { + ...commonProps, + referredUsersCount: 0, + transitions: [ + { + on: FunnelStepTransitionType.Complete, + destination: COMPLETED_STEP_ID, + }, + ], + }, +};