Skip to content

Commit

Permalink
feat: onboarding product intro (#17189)
Browse files Browse the repository at this point in the history
  • Loading branch information
raquelmsmith authored Aug 30, 2023
1 parent 4a828b1 commit 72110be
Show file tree
Hide file tree
Showing 15 changed files with 341 additions and 126 deletions.
9 changes: 9 additions & 0 deletions frontend/src/lib/lemon-ui/LemonCard/LemonCard.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.LemonCard {
transition: 200ms ease;
&.LemonCard--hoverEffect {
&:hover {
transform: scale(1.01);
box-shadow: var(--shadow-elevation);
}
}
}
19 changes: 19 additions & 0 deletions frontend/src/lib/lemon-ui/LemonCard/LemonCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import './LemonCard.scss'

export interface LemonCardProps {
hoverEffect?: boolean
className?: string
children?: React.ReactNode
}

export function LemonCard({ hoverEffect = true, className, children }: LemonCardProps): JSX.Element {
return (
<div
className={`LemonCard ${
hoverEffect && 'LemonCard--hoverEffect'
} border border-border rounded-lg p-6 bg-white ${className}`}
>
{children}
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/scenes/appScenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,5 @@ export const appScenes: Record<Scene, () => any> = {
[Scene.Feedback]: () => import('./feedback/Feedback'),
[Scene.Notebook]: () => import('./notebooks/NotebookScene'),
[Scene.Products]: () => import('./products/Products'),
[Scene.Onboarding]: () => import('./onboarding/Onboarding'),
}
101 changes: 54 additions & 47 deletions frontend/src/scenes/billing/BillingProduct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ import { ProductPricingModal } from './ProductPricingModal'
import { PlanComparisonModal } from './PlanComparisonModal'

export const getTierDescription = (
tier: BillingV2TierType,
tiers: BillingV2TierType[],
i: number,
product: BillingProductV2Type | BillingProductV2AddonType,
interval: string
): string => {
return i === 0
? `First ${summarizeUsage(tier.up_to)} ${product.unit}s / ${interval}`
: tier.up_to
? `${summarizeUsage(product.tiers?.[i - 1].up_to || null)} - ${summarizeUsage(tier.up_to)}`
: `> ${summarizeUsage(product.tiers?.[i - 1].up_to || null)}`
? `First ${summarizeUsage(tiers[i].up_to)} ${product.unit}s / ${interval}`
: tiers[i].up_to
? `${summarizeUsage(tiers?.[i - 1].up_to || null)} - ${summarizeUsage(tiers[i].up_to)}`
: `> ${summarizeUsage(tiers?.[i - 1].up_to || null)}`
}

export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonType }): JSX.Element => {
Expand Down Expand Up @@ -209,49 +209,56 @@ export const BillingProduct = ({ product }: { product: BillingProductV2Type }):

// TODO: SUPPORT NON-TIERED PRODUCT TYPES
// still use the table, but the data will be different
const tableTierData: TableTierDatum[] | undefined = product.tiers
?.map((tier, i) => {
const addonPricesForTier = product.addons?.map((addon) => ({
[`${addon.type}-price`]: `${
addon.tiers?.[i]?.unit_amount_usd !== '0' ? '$' + addon.tiers?.[i]?.unit_amount_usd : 'Free'
}`,
}))
// take the tier.current_amount_usd and add it to the same tier level for all the addons
const totalForTier =
parseFloat(tier.current_amount_usd || '') +
(product.addons?.reduce(
(acc, addon) => acc + parseFloat(addon.tiers?.[i]?.current_amount_usd || ''),
0
// if there aren't any addons we get NaN from the above, so we need to default to 0
) || 0)
const projectedTotalForTier =
(parseFloat(tier.projected_amount_usd || '') || 0) +
product.addons?.reduce(
(acc, addon) => acc + (parseFloat(addon.tiers?.[i]?.projected_amount_usd || '') || 0),
0
)
const tableTierData: TableTierDatum[] | undefined =
product.tiers && product.tiers.length > 0
? product.tiers
?.map((tier, i) => {
const addonPricesForTier = product.addons?.map((addon) => ({
[`${addon.type}-price`]: `${
addon.tiers?.[i]?.unit_amount_usd !== '0'
? '$' + addon.tiers?.[i]?.unit_amount_usd
: 'Free'
}`,
}))
// take the tier.current_amount_usd and add it to the same tier level for all the addons
const totalForTier =
parseFloat(tier.current_amount_usd || '') +
(product.addons?.reduce(
(acc, addon) => acc + parseFloat(addon.tiers?.[i]?.current_amount_usd || ''),
0
// if there aren't any addons we get NaN from the above, so we need to default to 0
) || 0)
const projectedTotalForTier =
(parseFloat(tier.projected_amount_usd || '') || 0) +
product.addons?.reduce(
(acc, addon) => acc + (parseFloat(addon.tiers?.[i]?.projected_amount_usd || '') || 0),
0
)

const tierData = {
volume: getTierDescription(tier, i, product, billing?.billing_period?.interval || ''),
basePrice: tier.unit_amount_usd !== '0' ? `$${tier.unit_amount_usd}` : 'Free',
usage: compactNumber(tier.current_usage),
total: `$${totalForTier.toFixed(2) || '0.00'}`,
projectedTotal: `$${projectedTotalForTier.toFixed(2) || '0.00'}`,
}
// if there are any addon prices we need to include, put them in the table
addonPricesForTier?.map((addonPrice) => {
Object.assign(tierData, addonPrice)
})
return tierData
})
// Add a row at the end for the total
.concat({
volume: 'Total',
basePrice: '',
usage: '',
total: `$${product.current_amount_usd || '0.00'}`,
projectedTotal: `$${product.projected_amount_usd || '0.00'}`,
})
const tierData = {
volume: product.tiers // this is silly because we know there are tiers since we check above, but typescript doesn't
? getTierDescription(product.tiers, i, product, billing?.billing_period?.interval || '')
: '',
basePrice: tier.unit_amount_usd !== '0' ? `$${tier.unit_amount_usd}` : 'Free',
usage: compactNumber(tier.current_usage),
total: `$${totalForTier.toFixed(2) || '0.00'}`,
projectedTotal: `$${projectedTotalForTier.toFixed(2) || '0.00'}`,
}
// if there are any addon prices we need to include, put them in the table
addonPricesForTier?.map((addonPrice) => {
Object.assign(tierData, addonPrice)
})
return tierData
})
// Add a row at the end for the total
.concat({
volume: 'Total',
basePrice: '',
usage: '',
total: `$${product.current_amount_usd || '0.00'}`,
projectedTotal: `$${product.projected_amount_usd || '0.00'}`,
})
: undefined

if (billing?.discount_percent && parseFloat(product.projected_amount_usd || '')) {
// If there is a discount, add a row for the total after discount if there is also a projected amount
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/scenes/billing/ProductPricingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const ProductPricingModal = ({
<span className="text-gray">/{product.unit}</span>
</p>
{isFirstTierFree && (
<p className="text-gray">{getTierDescription(tiers[0], 0, product, 'month')} free</p>
<p className="text-gray">{getTierDescription(tiers, 0, product, 'month')} free</p>
)}
<div>
<h4 className="font-bold">Volume discounts</h4>
Expand All @@ -53,7 +53,7 @@ export const ProductPricingModal = ({
className="flex justify-between border-b border-border border-dashed py-1 gap-x-8"
>
<p className="col-span-1 mb-0">
{getTierDescription(tier, i, product, 'month')}
{getTierDescription(tiers, i, product, 'month')}
</p>
<p className="font-bold mb-0 ">
{isFirstTierFree && i === 0
Expand Down
153 changes: 153 additions & 0 deletions frontend/src/scenes/onboarding/Onboarding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { SceneExport } from 'scenes/sceneTypes'
import { useActions, useValues } from 'kea'
import { useEffect } from 'react'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
import { urls } from 'scenes/urls'
import { LemonButton, Link } from '@posthog/lemon-ui'
import { onboardingLogic } from './onboardingLogic'
import { billingProductLogic } from 'scenes/billing/billingProductLogic'
import { convertLargeNumberToWords } from 'scenes/billing/billing-utils'
import { BillingProductV2Type } from '~/types'
import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard'
import { ProductPricingModal } from 'scenes/billing/ProductPricingModal'
import { IconCheckCircleOutline, IconOpenInNew } from 'lib/lemon-ui/icons'

export const scene: SceneExport = {
component: Onboarding,
logic: onboardingLogic,
}

const OnboardingProductIntro = ({ product }: { product: BillingProductV2Type }): JSX.Element => {
const { currentAndUpgradePlans, isPricingModalOpen } = useValues(billingProductLogic({ product }))
const { toggleIsPricingModalOpen } = useActions(billingProductLogic({ product }))
const upgradePlan = currentAndUpgradePlans?.upgradePlan

const pricingBenefits = [
'Only pay for what you use',
'Control spend with billing limits as low as $0/mo',
'Generous free volume every month, forever',
]

const productWebsiteKey = product.type.replace('_', '-')
const communityUrl = 'https://posthog.com/questions/topic/' + productWebsiteKey
const tutorialsUrl = 'https://posthog.com/tutorials/categories/' + productWebsiteKey
const productPageUrl = 'https://posthog.com/' + productWebsiteKey
const productImageUrl = `https://posthog.com/images/product/${productWebsiteKey}-product.png`

return (
<div className="w-full">
<div className="flex flex-col w-full p-6 bg-mid items-center justify-center">
<div className="max-w-lg flex flex-wrap my-8 items-center">
<div className="w-1/2 pr-6 min-w-80">
<h1 className="text-5xl font-bold">{product.name}</h1>
<h2 className="font-bold mb-6">{product.description}</h2>
<div className="flex gap-x-2">
<LemonButton type="primary">Get started</LemonButton>
{product.docs_url && (
<LemonButton type="secondary" to={productPageUrl}>
Learn more
</LemonButton>
)}
</div>
</div>
<div className="shrink w-1/2 min-w-80">
<img src={productImageUrl} className="w-full" />
</div>
</div>
</div>
<div className="my-12 flex justify-between mx-auto max-w-lg gap-x-8">
<div className="flex flex-col">
<h2 className="text-3xl">Features</h2>
<div className="flex flex-wrap gap-y-4 my-6 max-w-lg">
{upgradePlan.features.map((feature, i) => (
<li className="flex mb-2" key={`product-features-${i}`}>
<div>
<IconCheckCircleOutline className="text-success mr-2 mt-1 w-6" />
</div>
<div>
<h4 className="font-bold mb-0">{feature.name}</h4>
<p className="m-0">{feature.description}</p>
</div>
</li>
))}
</div>
</div>
<div>
<LemonCard hoverEffect={false}>
<h2 className="text-3xl">Pricing</h2>
<p>
{upgradePlan?.tiers?.[0].unit_amount_usd &&
parseInt(upgradePlan?.tiers?.[0].unit_amount_usd) === 0 && (
<p className="ml-0 mb-0 mt-4">
<span className="font-bold">
First {convertLargeNumberToWords(upgradePlan?.tiers?.[0].up_to, null)}{' '}
{product.unit}s free
</span>
, then{' '}
<span className="font-bold">${upgradePlan?.tiers?.[1].unit_amount_usd}</span>
<span className="text-muted">/{product.unit}</span>.{' '}
<Link
onClick={() => {
toggleIsPricingModalOpen()
}}
>
<span className="font-bold text-brand-red">Volume discounts</span>
</Link>{' '}
after {convertLargeNumberToWords(upgradePlan?.tiers?.[1].up_to, null)}/mo.
</p>
)}
</p>
<ul>
{pricingBenefits.map((benefit, i) => (
<li className="flex mb-2 ml-6" key={`pricing-benefits-${i}`}>
<IconCheckCircleOutline className="text-success mr-2 mt-1" />
{benefit}
</li>
))}
</ul>
</LemonCard>
<LemonCard className="mt-8" hoverEffect={false}>
<h2 className="text-3xl">Resources</h2>
{product.docs_url && (
<p>
<Link to={product.docs_url}>
Documentation <IconOpenInNew />
</Link>
</p>
)}
<p>
<Link to={communityUrl}>
Community forum <IconOpenInNew />
</Link>
</p>
<p>
<Link to={tutorialsUrl}>
Tutorials <IconOpenInNew />
</Link>
</p>
</LemonCard>
</div>
</div>
<ProductPricingModal
modalOpen={isPricingModalOpen}
onClose={toggleIsPricingModalOpen}
product={product}
planKey={upgradePlan.plan_key}
/>
</div>
)
}

export function Onboarding(): JSX.Element | null {
const { featureFlags } = useValues(featureFlagLogic)
const { product } = useValues(onboardingLogic)

useEffect(() => {
if (featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] !== 'test') {
location.href = urls.ingestion()
}
}, [])

return product ? <OnboardingProductIntro product={product} /> : null
}
66 changes: 66 additions & 0 deletions frontend/src/scenes/onboarding/onboardingLogic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { kea } from 'kea'
import { BillingProductV2Type, ProductKey } from '~/types'
import { urls } from 'scenes/urls'

import type { onboardingLogicType } from './onboardingLogicType'
import { billingLogic } from 'scenes/billing/billingLogic'

export interface OnboardingLogicProps {
productKey: ProductKey | null
}

export const onboardingLogic = kea<onboardingLogicType>({
props: {} as OnboardingLogicProps,
path: ['scenes', 'onboarding', 'onboardingLogic'],
connect: {
values: [billingLogic, ['billing']],
actions: [billingLogic, ['loadBillingSuccess']],
},
actions: {
setProduct: (product: BillingProductV2Type | null) => ({ product }),
setProductKey: (productKey: string | null) => ({ productKey }),
},
reducers: () => ({
productKey: [
null as string | null,
{
setProductKey: (_, { productKey }) => productKey,
},
],
product: [
null as BillingProductV2Type | null,
{
setProduct: (_, { product }) => product,
},
],
}),
listeners: ({ actions, values }) => ({
loadBillingSuccess: () => {
actions.setProduct(values.billing?.products.find((p) => p.type === values.productKey) || null)
},
setProduct: ({ product }) => {
if (!product) {
window.location.href = urls.default()
return
}
},
setProductKey: ({ productKey }) => {
if (!productKey) {
window.location.href = urls.default()
return
}
if (values.billing?.products?.length) {
actions.setProduct(values.billing?.products.find((p) => p.type === values.productKey) || null)
}
},
}),
urlToAction: ({ actions }) => ({
'/onboarding/:productKey': ({ productKey }) => {
if (!productKey) {
window.location.href = urls.default()
return
}
actions.setProductKey(productKey)
},
}),
})
Loading

0 comments on commit 72110be

Please sign in to comment.