Skip to content
Snippets Groups Projects
Unverified Commit 43084103 authored by Ryan Laurie's avatar Ryan Laurie Committed by GitHub
Browse files

Basic Upsell System Setup + Hosting Upsell (#42785)

* Basic Upsell System Setup

* Hosting Upsell

* add upsell to updates page

* update utms

* update copy, utms and text
parent f225c927
Branches
Tags
No related merge requests found
Showing
with 500 additions and 170 deletions
......@@ -21,18 +21,14 @@ describe("scenarios > admin > settings", () => {
});
it(
"should prompt admin to migrate to the hosted instance",
"should prompt admin to migrate to a hosted instance",
{ tags: "@OSS" },
() => {
cy.onlyOn(isOSS);
cy.visit("/admin/settings/setup");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Have your server maintained for you.");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Migrate to Metabase Cloud.");
cy.findAllByRole("link", { name: "Learn more" })
.should("have.attr", "href")
.and("include", "/migrate/");
cy.findByText(/Migrate to Metabase Cloud/);
cy.findAllByRole("link", { name: "Learn more" }).should("be.visible");
},
);
......
......@@ -6,6 +6,7 @@ import _ from "underscore";
import * as Yup from "yup";
import type { SettingElement } from "metabase/admin/settings/types";
import { UpsellHosting } from "metabase/admin/upsells";
import Breadcrumbs from "metabase/components/Breadcrumbs";
import CS from "metabase/css/core/index.css";
import {
......@@ -19,9 +20,8 @@ import * as MetabaseAnalytics from "metabase/lib/analytics";
import { color } from "metabase/lib/colors";
import * as Errors from "metabase/lib/errors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { getIsPaidPlan } from "metabase/selectors/settings";
import { getIsEmailConfigured, getIsHosted } from "metabase/setup/selectors";
import { Group, Radio, Stack, Button, Text, Flex } from "metabase/ui";
import { Group, Radio, Stack, Button, Text, Flex, Box } from "metabase/ui";
import type { Settings } from "metabase-types/api";
import {
......@@ -29,7 +29,6 @@ import {
updateEmailSettings,
clearEmailSettings,
} from "../../settings";
import MarginHostingCTA from "../widgets/MarginHostingCTA";
const BREADCRUMBS = [[t`Email`, "/admin/settings/email"], [t`SMTP`]];
......@@ -74,7 +73,6 @@ export const SMTPConnectionForm = ({
const [testEmailError, setTestEmailError] = useState<string | null>(null);
const isHosted = useSelector(getIsHosted);
const isPaidPlan = useSelector(getIsPaidPlan);
const isEmailConfigured = useSelector(getIsEmailConfigured);
const dispatch = useDispatch();
......@@ -266,9 +264,9 @@ export const SMTPConnectionForm = ({
)}
</FormProvider>
</Stack>
{!isPaidPlan && (
<MarginHostingCTA tagline={t`Have your email configured for you.`} />
)}
<Box>
<UpsellHosting source="settings-email-migrate_to_cloud" />
</Box>
</Flex>
);
};
......@@ -2,8 +2,10 @@ import { useEffect } from "react";
import { push } from "react-router-redux";
import type { SettingElement } from "metabase/admin/settings/types";
import { UpsellHosting } from "metabase/admin/upsells";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { getIsEmailConfigured, getIsHosted } from "metabase/setup/selectors";
import { Flex, Box } from "metabase/ui";
import type { Settings, SettingValue } from "metabase-types/api";
import { SettingsSection } from "../../app/components/SettingsEditor/SettingsSection";
......@@ -44,15 +46,20 @@ export function SettingsEmailForm({
);
return (
<>
{!isHosted && <SMTPConnectionCard />}
<SettingsSection
settingElements={settingElements}
settingValues={settingValues}
derivedSettingValues={derivedSettingValues}
updateSetting={updateSetting}
reloadSettings={reloadSettings}
/>
</>
<Flex justify="space-between">
<Box>
{!isHosted && <SMTPConnectionCard />}
<SettingsSection
settingElements={settingElements}
settingValues={settingValues}
derivedSettingValues={derivedSettingValues}
updateSetting={updateSetting}
reloadSettings={reloadSettings}
/>
</Box>
<Box>
<UpsellHosting source="settings-email-migrate_to_cloud" />
</Box>
</Flex>
);
}
import cx from "classnames";
import PropTypes from "prop-types";
import { UpsellHostingUpdates } from "metabase/admin/upsells";
import CS from "metabase/css/core/index.css";
import MetabaseSettings from "metabase/lib/settings";
import { Flex } from "metabase/ui";
import { SettingsSetting } from "../SettingsSetting";
import VersionUpdateNotice from "./VersionUpdateNotice/VersionUpdateNotice";
export default function SettingsUpdatesForm({ elements, updateSetting }) {
const settings = elements.map((setting, index) => (
<SettingsSetting
......@@ -19,19 +20,24 @@ export default function SettingsUpdatesForm({ elements, updateSetting }) {
));
return (
<div style={{ width: "585px" }}>
{!MetabaseSettings.isHosted() && <ul>{settings}</ul>}
<Flex justify="space-between">
<div style={{ width: "585px" }}>
{!MetabaseSettings.isHosted() && <ul>{settings}</ul>}
<div className={CS.px2}>
<div
className={cx(CS.pt3, {
[CS.borderTop]: !MetabaseSettings.isHosted(),
})}
>
<VersionUpdateNotice />
<div className={CS.px2}>
<div
className={cx(CS.pt3, {
[CS.borderTop]: !MetabaseSettings.isHosted(),
})}
>
<VersionUpdateNotice />
</div>
</div>
</div>
</div>
<div>
<UpsellHostingUpdates source="settings-updates-migrate_to_cloud" />
</div>
</Flex>
);
}
......
......@@ -42,6 +42,7 @@ function setup({
const state = createMockState({
settings,
currentUser: { is_superuser: true },
});
renderWithProviders(<SettingsUpdatesForm elements={elements} />, {
......@@ -71,14 +72,14 @@ describe("SettingsUpdatesForm", () => {
it("shows upgrade call-to-action if not in Enterprise plan", () => {
setup({ currentVersion: "v1.0.0", latestVersion: "v1.0.0" });
expect(screen.getByText("Migrate to Metabase Cloud.")).toBeInTheDocument();
expect(screen.getByText("Get automatic updates")).toBeInTheDocument();
});
it("does not show upgrade call-to-action if is a paid plan", () => {
setup({ currentVersion: "v1.0.0", latestVersion: "v2.0.0", isPaid: true });
expect(
screen.queryByText("Migrate to Metabase Cloud."),
screen.queryByText("Get automatic updates."),
).not.toBeInTheDocument();
});
});
......@@ -2,20 +2,12 @@ import cx from "classnames";
import PropTypes from "prop-types";
import { t } from "ttag";
import HostingInfoLink from "metabase/admin/settings/components/widgets/HostingInfoLink";
import Text from "metabase/components/type/Text";
import ExternalLink from "metabase/core/components/ExternalLink";
import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import MetabaseSettings from "metabase/lib/settings";
import { getIsPaidPlan } from "metabase/selectors/settings";
import { Icon } from "metabase/ui";
import {
HostingCTAContent,
HostingCTAIconContainer,
HostingCTARoot,
NewVersionContainer,
OnLatestVersionMessage,
} from "./VersionUpdateNotice.styled";
......@@ -51,14 +43,11 @@ CloudCustomers.propTypes = {
};
function OnLatestVersion({ currentVersion }) {
const isPaidPlan = useSelector(getIsPaidPlan);
return (
<div>
<OnLatestVersionMessage>
{t`You're running Metabase ${currentVersion} which is the latest and greatest!`}
</OnLatestVersionMessage>
{!isPaidPlan && <HostingCTA />}
</div>
);
}
......@@ -70,7 +59,6 @@ OnLatestVersion.propTypes = {
function NewVersionAvailable({ currentVersion }) {
const latestVersion = MetabaseSettings.latestVersion();
const versionInfo = MetabaseSettings.versionInfo();
const isPaidPlan = useSelector(getIsPaidPlan);
return (
<div>
......@@ -127,8 +115,6 @@ function NewVersionAvailable({ currentVersion }) {
<Version key={index} version={version} />
))}
</div>
{!isPaidPlan && <HostingCTA />}
</div>
);
}
......@@ -137,47 +123,6 @@ NewVersionAvailable.propTypes = {
currentVersion: PropTypes.string.isRequired,
};
function HostingCTA() {
return (
<HostingCTARoot
className={cx(
CS.rounded,
CS.bgLight,
CS.mt4,
CS.textBrand,
CS.py2,
CS.px1,
)}
>
<HostingCTAContent>
<HostingCTAIconContainer
className={cx(
CS.circular,
CS.bgMedium,
CS.alignCenter,
CS.justifyCenter,
CS.ml1,
CS.mr2,
)}
>
<Icon name="cloud" size={24} />
</HostingCTAIconContainer>
<div>
<Text
className={cx(CS.textBrand, CS.mb0)}
>{t`Want to have upgrades taken care of for you?`}</Text>
<Text
className={cx(CS.textBrand, CS.textBold)}
>{t`Migrate to Metabase Cloud.`}</Text>
</div>
</HostingCTAContent>
<div className={CS.pr1}>
<HostingInfoLink text={t`Learn more`} />
</div>
</HostingCTARoot>
);
}
function Version({ version }) {
if (!version) {
return null;
......
......@@ -2,22 +2,6 @@ import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
export const HostingCTARoot = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export const HostingCTAContent = styled.div`
display: flex;
`;
export const HostingCTAIconContainer = styled.div`
display: flex;
width: 3.25rem;
height: 2rem;
`;
export const NewVersionContainer = styled.div`
background-color: ${color("summarize")};
`;
......
/* eslint-disable react/prop-types */
import cx from "classnames";
import { t } from "ttag";
import HostingInfoLink from "metabase/admin/settings/components/widgets/HostingInfoLink";
import Text from "metabase/components/type/Text";
import CS from "metabase/css/core/index.css";
import { Icon } from "metabase/ui";
const MarginHostingCTA = ({ tagline }) => (
<div
className={cx(CS.borderLeft, CS.borderBrand, CS.textBrand, CS.px4)}
style={{ height: 172 }}
>
<Icon name="cloud" size={48} style={{ color: "#B9D8F4" }} />
<div className={CS.pb3}>
<Text className={cx(CS.textBrand, CS.mb0)}>{tagline}</Text>
<Text
className={cx(CS.textBrand, CS.textBold)}
>{t`Migrate to Metabase Cloud.`}</Text>
</div>
<HostingInfoLink text={t`Learn more`} />
</div>
);
export default MarginHostingCTA;
......@@ -4,15 +4,14 @@ import { Component } from "react";
import { connect } from "react-redux";
import { t } from "ttag";
import MarginHostingCTA from "metabase/admin/settings/components/widgets/MarginHostingCTA";
import { UpsellHosting } from "metabase/admin/upsells";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { color } from "metabase/lib/colors";
import { isSameOrSiteUrlOrigin } from "metabase/lib/dom";
import MetabaseSettings from "metabase/lib/settings";
import { getIsPaidPlan } from "metabase/selectors/settings";
import { SetupApi } from "metabase/services";
import { Icon } from "metabase/ui";
import { Box, Flex, Icon } from "metabase/ui";
import {
SetupListRoot,
......@@ -113,8 +112,6 @@ class SetupCheckList extends Component {
}
render() {
const { isPaidPlan } = this.props;
let tasks, nextTask;
if (this.state.tasks) {
tasks = this.state.tasks.map(section => ({
......@@ -129,36 +126,37 @@ class SetupCheckList extends Component {
}
return (
<SetupListRoot>
<div className={CS.px2}>
<h2>{t`Getting set up`}</h2>
<p
className={CS.mt1}
>{t`A few things you can do to get the most out of Metabase.`}</p>
<LoadingAndErrorWrapper
loading={!this.state.tasks}
error={this.state.error}
>
{() => (
<div style={{ maxWidth: 468 }}>
{nextTask && (
<TaskSection
name={t`Recommended next step`}
tasks={[nextTask]}
/>
)}
{tasks.map((section, index) => (
<TaskSection {...section} key={index} />
))}
</div>
)}
</LoadingAndErrorWrapper>
</div>
{!MetabaseSettings.isHosted() && !isPaidPlan && (
<MarginHostingCTA tagline={t`Have your server maintained for you.`} />
)}
</SetupListRoot>
<Flex justify="space-between">
<SetupListRoot>
<div className={CS.px2}>
<h2>{t`Getting set up`}</h2>
<p
className={CS.mt1}
>{t`A few things you can do to get the most out of Metabase.`}</p>
<LoadingAndErrorWrapper
loading={!this.state.tasks}
error={this.state.error}
>
{() => (
<div style={{ maxWidth: 468 }}>
{nextTask && (
<TaskSection
name={t`Recommended next step`}
tasks={[nextTask]}
/>
)}
{tasks.map((section, index) => (
<TaskSection {...section} key={index} />
))}
</div>
)}
</LoadingAndErrorWrapper>
</div>
</SetupListRoot>
<Box>
<UpsellHosting source="settings-setup-migrate_to_cloud" />
</Box>
</Flex>
);
}
}
......
import { t, jt } from "ttag";
const RocketGlobeIllustrationSrc = "app/assets/img/rocket-globe.svg";
import { useSelector } from "metabase/lib/redux";
import { getIsHosted } from "metabase/setup/selectors";
import { UpsellCard } from "./components";
export const UpsellHosting = ({ source }: { source: string }) => {
const isHosted = useSelector(getIsHosted);
if (isHosted) {
return null;
}
return (
<UpsellCard
title={t`Minimize maintenance`}
campaign="hosting"
buttonText={t`Learn more`}
buttonLink="https://www.metabase.com/cloud"
illustrationSrc={RocketGlobeIllustrationSrc}
source={source}
>
{jt`${(
<strong>{t`Migrate to Metabase Cloud`}</strong>
)} for fast, reliable, and secure deployment.`}
</UpsellCard>
);
};
export const UpsellHostingUpdates = ({ source }: { source: string }) => {
const isHosted = useSelector(getIsHosted);
if (isHosted) {
return null;
}
return (
<UpsellCard
title={t`Get automatic updates`}
campaign="hosting"
buttonText={t`Learn more`}
buttonLink="https://www.metabase.com/cloud"
illustrationSrc={RocketGlobeIllustrationSrc}
source={source}
>
{jt`${(
<strong>{t`Migrate to Metabase Cloud`}</strong>
)} for fast, reliable, and secure deployment.`}
</UpsellCard>
);
};
import { Canvas, Story, Meta } from "@storybook/addon-docs";
import { ReduxProvider } from "__support__/storybook";
import { _UpsellCard } from "./UpsellCard";
export const args = {
title: "Ice Cream",
buttonText: "Get Some",
buttonLink: "https://www.metabase.com",
campaign: "ice-cream",
source: "ice-cream-page-footer",
illustrationSrc: "https://i.imgur.com/789Q56R.png",
children: "You wouldn't believe how great this stuff is.",
};
export const argTypes = {
children: {
control: { type: "text" },
},
buttonText: {
control: { type: "text" },
},
buttonLink: {
control: { type: "text" },
},
illustrationSrc: {
control: { type: "text" },
},
campaign: {
control: { type: "text" },
},
source: {
control: { type: "text" },
},
children: {
control: { type: "text" },
}
};
<Meta
title="Upsells/Card"
component={_UpsellCard}
args={args}
argTypes={argTypes}
/>
# Upsell Card
- Use as a small, visible upsell, with or without an image
## Examples
export const DefaultTemplate = (args) => (
<ReduxProvider>
<Flex justify="center">
<_UpsellCard {...args} />
</Flex>
</ReduxProvider>
);
export const WithImage = DefaultTemplate.bind({});
export const WithoutImage = DefaultTemplate.bind({});
WithoutImage.args = { ...args, illustrationSrc: null};
<Canvas>
<Story name="With Image">{WithImage}</Story>
</Canvas>
<Canvas>
<Story name="Without Image">{WithoutImage}</Story>
</Canvas>
import { Flex, Image, Text } from "metabase/ui";
import { UpsellGem } from "./UpsellGem";
import { UpsellWrapper } from "./UpsellWrapper";
import { UpsellCTALink, UpsellCardComponent } from "./Upsells.styled";
import { useUpsellLink } from "./use-upsell-link";
type UpsellCardProps = {
title: string;
buttonText: string;
buttonLink: string;
campaign: string;
source: string;
illustrationSrc?: string;
children: React.ReactNode;
};
export const _UpsellCard = ({
title,
buttonText,
buttonLink,
campaign,
source,
illustrationSrc,
children,
}: UpsellCardProps) => {
const url = useUpsellLink({
url: buttonLink,
campaign,
source,
});
return (
<UpsellCardComponent>
{illustrationSrc && <Image src={illustrationSrc} w="100%" />}
<Flex gap="sm" justify="center" p="1rem" pb="0.75rem">
<UpsellGem />
<Text fw="bold" size="0.875rem">
{title}
</Text>
</Flex>
<Text size="0.75rem" lh="1rem" px="1rem" pb="1rem">
{children}
</Text>
<UpsellCTALink href={url}>{buttonText}</UpsellCTALink>
</UpsellCardComponent>
);
};
export const UpsellCard = UpsellWrapper(_UpsellCard);
import { FixedSizeIcon } from "metabase/ui";
import { upsellColors } from "./Upsells.styled";
export const UpsellGem = ({ size = 16 }: { size?: number }) => {
return <FixedSizeIcon name="gem" size={size} color={upsellColors.gem} />;
};
import { Box } from "metabase/ui";
import { Canvas, Story, Meta } from "@storybook/addon-docs";
import { ReduxProvider } from "__support__/storybook";
import { _UpsellPill } from "./UpsellPill";
export const args = {
children: "Metabase Enterprise is so great",
link: "https://www.metabase.com",
campaign: "enterprise",
source: "enterprise-page-footer",
};
export const argTypes = {
children: {
control: { type: "text" },
},
link: {
control: { type: "text" },
},
campaign: {
control: { type: "text" },
},
source: {
control: { type: "text" },
},
};
<Meta
title="Upsells/Pill"
component={_UpsellPill}
args={args}
argTypes={argTypes}
/>
# Upsell Pill
## Examples
export const DefaultTemplate = (args) => (
<ReduxProvider>
<Box>
<_UpsellPill {...args} />
</Box>
</ReduxProvider>
);
export const Default = DefaultTemplate.bind({});
export const NarrowTemplate = (args) => (
<ReduxProvider>
<Box style={{ maxWidth: "10rem"}}>
<_UpsellPill {...args} />
</Box>
</ReduxProvider>
);
export const Narrow = NarrowTemplate.bind({});
<Canvas>
<Story name="Default">{Default}</Story>
</Canvas>
<Canvas>
<Story name="Multiline">{Narrow}</Story>
</Canvas>
import { UpsellGem } from "./UpsellGem";
import { UpsellWrapper } from "./UpsellWrapper";
import { UpsellPillComponent } from "./Upsells.styled";
import { useUpsellLink } from "./use-upsell-link";
export function _UpsellPill({
children,
link,
campaign,
source,
}: {
children: React.ReactNode;
link: string;
campaign: string;
source: string;
}) {
const url = useUpsellLink({
url: link,
campaign,
source,
});
return (
<UpsellPillComponent href={url}>
<UpsellGem />
{children}
</UpsellPillComponent>
);
}
export const UpsellPill = UpsellWrapper(_UpsellPill);
import { useSelector } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
/**
* we should wrap all upsell components in this HoC to ensure that they are only rendered for admins
*/
export function UpsellWrapper<Props>(Component: React.ComponentType<Props>) {
const WrappedComponent = (props: Props) => {
const isAdmin = useSelector(getUserIsAdmin);
if (!isAdmin) {
return null;
}
return <Component {...(props as any)} />;
};
return WrappedComponent;
}
import { renderWithProviders, screen } from "__support__/ui";
import { createMockUser } from "metabase-types/api/mocks";
import { UpsellWrapper } from "./UpsellWrapper";
const Component = () => <div>Hello, beautiful</div>;
describe("Upsells > UpsellWrapper", () => {
it("should show a component for admins", () => {
const WrappedComponent = UpsellWrapper(Component);
renderWithProviders(<WrappedComponent />, {
storeInitialState: {
currentUser: createMockUser({ is_superuser: true }),
},
});
expect(screen.getByText("Hello, beautiful")).toBeInTheDocument();
});
it("should not show component for non-=admins", () => {
const WrappedComponent = UpsellWrapper(Component);
renderWithProviders(<WrappedComponent />, {
storeInitialState: {
currentUser: createMockUser({ is_superuser: false }),
},
});
expect(screen.queryByText("Hello, beautiful")).not.toBeInTheDocument();
});
});
import styled from "@emotion/styled";
import ExternalLink from "metabase/core/components/ExternalLink";
import { color } from "metabase/lib/colors";
/**
* The upsell color palette is designed to function in harmony with the original Metabase set of Blues, Grays and Purples
* but with a twist. All three colors are new and should not be used elsewhere in the product experience.
*/
export const upsellColors = {
primary: "#005999",
secondary: "#BFF4FF",
gem: "#00D4FF",
};
export const UpsellPillComponent = styled(ExternalLink)`
display: inline-flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
flex-grow: 0;
font-weight: bold;
font-size: 0.75rem;
text-decoration: none;
padding: 0.25rem 0.75rem;
border-radius: 2rem;
border: 1px solid ${upsellColors.secondary};
color: ${upsellColors.primary};
&:hover {
background-color: ${upsellColors.primary};
color: ${color("white")};
border: 1px solid ${upsellColors.primary};
}
`;
export const UpsellCTALink = styled(ExternalLink)`
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
flex-grow: 0;
font-weight: bold;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 2rem;
margin-inline: 1rem;
margin-bottom: 1.5rem;
color: ${upsellColors.primary};
background-color: ${upsellColors.secondary};
text-decoration: none;
&:hover {
background-color: ${upsellColors.primary};
color: ${color("white")};
}
`;
export const UpsellCardComponent = styled.div`
max-width: 200px;
box-sizing: content-box;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid ${upsellColors.secondary};
`;
export * from "./UpsellPill";
export * from "./UpsellCard";
import { useSetting } from "metabase/common/hooks";
import { getPlan } from "metabase/common/utils/plan";
interface UpsellLinkProps {
/* The URL we're sending them to */
url: string;
/* The name of the feature we're trying to sell */
campaign: string;
/* The source component/view of the upsell notification */
source: string;
}
/**
* We need to add extra anonymous information to upsell links to know where the user came from
*/
export const useUpsellLink = ({ url, campaign, source }: UpsellLinkProps) => {
const plan = getPlan(useSetting("token-features"));
return `${url}?utm_source=product&utm_medium=upsell&utm_campaign=${campaign}&utm_content=${source}&utm_source_plan=${plan}`;
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment