diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index f11d5a27f9cb..8a46b1ffc552 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -284,6 +284,23 @@ const Constants = { 'category': 'Onboarding', 'event': 'Onboarding flag toggled', }, + 'ONBOARDING_ROLLOUT_CONTINUED': { + 'category': 'Onboarding', + 'event': 'Onboarding rollout quest continued', + }, + 'ONBOARDING_ROLLOUT_FEEDBACK': { + 'category': 'Onboarding', + 'event': 'Onboarding rollout feedback opened', + }, + // The fake door's only real signal: demand for one-click rollouts. + 'ONBOARDING_ROLLOUT_NOTIFY_ME': { + 'category': 'Onboarding', + 'event': 'Onboarding rollout notify me', + }, + 'ONBOARDING_ROLLOUT_VIEWED': { + 'category': 'Onboarding', + 'event': 'Onboarding rollout quest viewed', + }, 'ONBOARDING_SNIPPET_COPIED': { 'category': 'Onboarding', 'event': 'Onboarding snippet copied', diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index c69406851763..2c2ea46f7033 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -10,12 +10,14 @@ import OnboardingFlagsTable from 'components/pages/onboarding/OnboardingFlagsTab import OnboardingNextSteps, { OnboardingNextStep, } from 'components/pages/onboarding/OnboardingNextSteps' +import { useRolloutQuest } from 'components/pages/onboarding/OnboardingRolloutQuest' import { useEnsureOnboardingResources } from 'components/pages/onboarding/hooks/useEnsureOnboardingResources' import { useOnboardingFlagRename } from 'components/pages/onboarding/hooks/useOnboardingFlagRename' import { useOnboardingFlag } from 'components/pages/onboarding/hooks/useOnboardingFlag' import { useOnboardingConnection } from 'components/pages/onboarding/hooks/useOnboardingConnection' import { useUpdateOrganisationMutation } from 'common/services/useOrganisation' import { useUpdateProjectMutation } from 'common/services/useProject' +import { useGetProfileQuery } from 'common/services/useProfile' import API from 'project/api' import Constants from 'common/constants' import { isPendingAuthorisation } from 'common/utils/pendingAuthorisation' @@ -41,6 +43,8 @@ const OnboardingFlow: FC = () => { const history = useHistory() const [updateOrganisation] = useUpdateOrganisationMutation() const [updateProject] = useUpdateProjectMutation() + // Already fetched by useEnsureOnboardingResources, so this is a cache read. + const { data: profile } = useGetProfileQuery({}) // A client waiting on the consent screen sent this user here to sign up. It // is answered once the workspace exists, never on load: bootstrapping is a @@ -144,8 +148,8 @@ const OnboardingFlow: FC = () => { } } - // Each next-step card deep-links to the flag's real config; nothing faked. - const goToNextStep = (step: OnboardingNextStep) => { + // Where a quest ends up: the flag's real config, nothing faked. + const goToFlagConfig = (step: OnboardingNextStep) => { if (projectId === null) { return } @@ -167,6 +171,21 @@ const OnboardingFlow: FC = () => { organisation_id: organisationId, project_id: projectId, } + const openRolloutQuest = useRolloutQuest({ + diagnosticIds, + featureName, + onContinue: () => goToFlagConfig('rollout'), + who: { email: profile?.email, organisation: organisationDisplayName }, + }) + + // Off, rollout deep-links to the overrides tab like every other next step. + const rolloutQuestEnabled = Utils.getFlagsmithHasFeature( + 'onboarding_rollout_quest', + ) + const goToNextStep = (step: OnboardingNextStep) => + step === 'rollout' && rolloutQuestEnabled + ? openRolloutQuest() + : goToFlagConfig(step) const trackSnippetCopied = (snippet: OnboardingSnippet) => API.trackEvent({ ...Constants.events.ONBOARDING_SNIPPET_COPIED, diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/OnboardingRolloutQuest.scss b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/OnboardingRolloutQuest.scss new file mode 100644 index 000000000000..964a09899f44 --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/OnboardingRolloutQuest.scss @@ -0,0 +1,13 @@ +.onboarding-rollout-quest { + // The fill alone is near-invisible in dark mode, where the card and the modal + // are a shade apart. .border-default sets only a colour, so the shorthand + // lives here, as it does in MetricsTable and VariationTable. + &__card { + border: 1px solid var(--color-border-default); + } + + &__step-number { + width: 24px; + height: 24px; + } +} diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/OnboardingRolloutQuest.tsx b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/OnboardingRolloutQuest.tsx new file mode 100644 index 000000000000..bec3451a93fb --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/OnboardingRolloutQuest.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import RolloutComingSoonCard from './RolloutComingSoonCard' +import { + ROLLOUT_GUIDE_URL, + RolloutPrerequisite, + getRolloutSteps, +} from './rolloutSteps' +import './OnboardingRolloutQuest.scss' + +export type OnboardingRolloutQuestProps = { + featureName: string + onContinue: () => void + onDismiss: () => void + onNotifyMe: () => void + onFeedback: () => void +} + +const OnboardingRolloutQuest: FC = ({ + featureName, + onContinue, + onDismiss, + onFeedback, + onNotifyMe, +}) => ( +
+

+ Release {featureName} to a growing percentage of your users. +

+ +
+
How to roll out gradually today
+
    + {getRolloutSteps(featureName).map((step, index) => ( +
  1. + + {index + 1} + + + {step.title} + + {step.body} + + +
  2. + ))} +
+ + +
+ + + +
+ + +
+
+) + +export default OnboardingRolloutQuest diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/RolloutComingSoonCard.tsx b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/RolloutComingSoonCard.tsx new file mode 100644 index 000000000000..5fbc605038cd --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/RolloutComingSoonCard.tsx @@ -0,0 +1,66 @@ +import React, { FC, useState } from 'react' +import flagsmith from '@flagsmith/flagsmith' +import Button from 'components/base/forms/Button' +import Chip from 'components/base/Chip' +import Icon from 'components/icons/Icon' +import { ROLLOUT_BETA_REQUESTED } from './trackRolloutInterest' + +export const ROLLOUT_FEEDBACK_URL = + 'mailto:support@flagsmith.com?subject=Gradual%20rollout' + +export type RolloutComingSoonCardProps = { + onNotifyMe: () => void + onFeedback: () => void +} + +// The one thing we don't ship yet: the three steps above in a single action. It +// promises a simpler flow, never the capability. +const RolloutComingSoonCard: FC = ({ + onFeedback, + onNotifyMe, +}) => { + // A trait rather than local state, so asking survives a reload and the beta + // can later be handed out by targeting it. Read at render, as Announcement + // does, so it still resolves if traits land after mount. + const [notified, setNotified] = useState(false) + const alreadyAsked = notified || !!flagsmith.getTrait(ROLLOUT_BETA_REQUESTED) + const notifyMe = () => { + setNotified(true) + flagsmith.setTrait(ROLLOUT_BETA_REQUESTED, true) + onNotifyMe() + } + return ( +
+ {/* Accent rather than the design's solid purple: there is no inverse + text token, and white on the dark-mode action surface is ~3.2:1. */} + + + Coming soon + +
We’re making gradual rollouts one-click
+

+ Automatically release according to a schedule, without manual editing of + segments. Want early access? +

+
+ {alreadyAsked ? ( + + + Thanks, we’ll be in touch. + + ) : ( + + )} + {/* No target: a mailto in a new tab leaves a blank tab behind. */} + +
+
+ ) +} + +export default RolloutComingSoonCard diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/index.ts b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/index.ts new file mode 100644 index 000000000000..90fc8dc7d31e --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/index.ts @@ -0,0 +1,3 @@ +export { default } from './OnboardingRolloutQuest' +export type { OnboardingRolloutQuestProps } from './OnboardingRolloutQuest' +export { default as useRolloutQuest } from './useRolloutQuest' diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/rolloutSteps.tsx b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/rolloutSteps.tsx new file mode 100644 index 000000000000..27f35251f8bc --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/rolloutSteps.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' + +export const ROLLOUT_GUIDE_URL = + 'https://docs.flagsmith.com/managing-flags/rollout/rollout-by-percentage' + +export const getRolloutSteps = ( + featureName: string, +): { title: string; body: string }[] => [ + { + body: 'Add a “Percentage split” rule and set your starting share, e.g. 10% of users.', + title: 'Create a segment', + }, + { + body: `Turn ${featureName} on for that segment, so only those users get it.`, + title: 'Override the flag', + }, + { + body: 'Raise the percentage as your confidence grows: 10% → 25% → 50% → 100%.', + title: 'Increase over time', + }, +] + +// Sits with the steps rather than in docs: without it the override reaches +// nobody and the user has no way to tell. +export const RolloutPrerequisite: FC = () => ( +

+ {/* info hardcodes a blue fill, so it needs telling to inherit. */} + + + Percentage splits only apply to users you identify. If your app doesn’t + call flagsmith.identify(...) yet, + add it first, or everyone keeps getting the same value. + +

+) diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/trackRolloutInterest.ts b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/trackRolloutInterest.ts new file mode 100644 index 000000000000..3e6f802ef661 --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/trackRolloutInterest.ts @@ -0,0 +1,26 @@ +import flagsmith from '@flagsmith/flagsmith' + +export const ROLLOUT_BETA_REQUESTED = 'rollout_beta_requested' +export const ROLLOUT_FEEDBACK_CLICKED = 'rollout_feedback_clicked' + +type RolloutInterest = { + email?: string + organisation?: string +} + +// The onboarding funnel events say an organisation wants this; this says who to +// reply to. Sent through flagsmith.trackEvent, same as the segment sources door. +const trackRolloutInterest = ( + event: string, + { email, organisation }: RolloutInterest, +): void => { + flagsmith.trackEvent(event, { + metadata: { + email, + organisation, + origin: 'onboarding-rollout-quest', + }, + }) +} + +export default trackRolloutInterest diff --git a/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/useRolloutQuest.ts b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/useRolloutQuest.ts new file mode 100644 index 000000000000..63604674356a --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingRolloutQuest/useRolloutQuest.ts @@ -0,0 +1,57 @@ +import React from 'react' +import API from 'project/api' +import Constants from 'common/constants' +import trackRolloutInterest, { + ROLLOUT_BETA_REQUESTED, + ROLLOUT_FEEDBACK_CLICKED, +} from './trackRolloutInterest' +import OnboardingRolloutQuest from './OnboardingRolloutQuest' + +type UseRolloutQuest = { + featureName: string + /** Where "Create a rollout segment" ends up. */ + onContinue: () => void + diagnosticIds: Record + /** Who to reply to when someone asks for access. */ + who: { email?: string; organisation?: string } +} + +// The segment overrides tab alone shows none of the steps a rollout takes, so +// this opens first. +const useRolloutQuest = ({ + diagnosticIds, + featureName, + onContinue, + who, +}: UseRolloutQuest): (() => void) => { + const track = (event: { category: string; event: string }) => + API.trackEvent({ ...event, extra: diagnosticIds }) + + return () => { + track(Constants.events.ONBOARDING_ROLLOUT_VIEWED) + openModal( + // Matches the next-step card that opens it. + 'Gradual rollout', + React.createElement(OnboardingRolloutQuest, { + featureName, + onContinue: () => { + track(Constants.events.ONBOARDING_ROLLOUT_CONTINUED) + closeModal() + onContinue() + }, + onDismiss: () => closeModal(), + onFeedback: () => { + track(Constants.events.ONBOARDING_ROLLOUT_FEEDBACK) + trackRolloutInterest(ROLLOUT_FEEDBACK_CLICKED, who) + }, + onNotifyMe: () => { + track(Constants.events.ONBOARDING_ROLLOUT_NOTIFY_ME) + trackRolloutInterest(ROLLOUT_BETA_REQUESTED, who) + }, + }), + 'modal--wide', + ) + } +} + +export default useRolloutQuest