diff --git a/www/src/components/account/billing/BillingManagePlan.tsx b/www/src/components/account/billing/BillingManagePlan.tsx index 0acc1a613..430e6cffa 100644 --- a/www/src/components/account/billing/BillingManagePlan.tsx +++ b/www/src/components/account/billing/BillingManagePlan.tsx @@ -1,31 +1,134 @@ -import { Div } from 'honorable' -import { useContext } from 'react' +import { Button, Flex } from '@pluralsh/design-system' -import SubscriptionContext from '../../../contexts/SubscriptionContext' +import { useCallback, useContext, useState } from 'react' -import BillingPreview from './BillingPreview' -import BillingPricingCards from './BillingPricingCards' +import { useSearchParams } from 'react-router-dom' + +import styled, { useTheme } from 'styled-components' + +import SubscriptionContext from 'contexts/SubscriptionContext' + +import BillingPricingCards, { ContactUs } from './BillingPricingCards' import BillingPricingTable from './BillingPricingTable' import ConfirmPayment from './ConfirmPayment' -function BillingManagePlan() { - const { isEnterprisePlan } = useContext(SubscriptionContext) +import BillingDowngradeModal from './BillingDowngradeModal' +import BillingStartTrialModal from './BillingStartTrialModal' +import BillingUpgradeToProfessionalModal from './BillingUpgradeToProfessionalModal' + +export default function BillingManagePlan() { + const theme = useTheme() + const [searchParams, setSearchParams] = useSearchParams() + + const [downgradeModalOpen, setDowngradeModalOpen] = useState(false) + + const upgradeToProfessionalModalOpen = + typeof searchParams.get('upgrade') === 'string' + const setUpgradeToProfessionalModalOpen = useCallback( + (isOpen) => { + setSearchParams((sp) => { + if (isOpen) { + sp.set('upgrade', '1') + } else { + sp.delete('upgrade') + } + + return sp + }) + }, + [setSearchParams] + ) + + const trialModalOpen = typeof searchParams.get('trial') === 'string' + const setOpenTrialModal = useCallback( + (isOpen) => { + setSearchParams((params) => { + if (isOpen) { + params.set('trial', '1') + } else { + params.delete('trial') + } + + return params + }) + }, + [setSearchParams] + ) return ( - <> + - {!isEnterprisePlan && } -
- -
-
- -
- + setUpgradeToProfessionalModalOpen(true)} + onCancel={() => setDowngradeModalOpen(true)} + /> + setUpgradeToProfessionalModalOpen(true)} + onCancel={() => setDowngradeModalOpen(true)} + /> + {/* Modals */} + setUpgradeToProfessionalModalOpen(false)} + /> + setDowngradeModalOpen(false)} + /> + setOpenTrialModal(false)} + /> +
) } -export default BillingManagePlan +export function ProPlanCTA({ + onUpgrade, + onCancel, +}: { + onUpgrade: () => void + onCancel: () => void +}) { + const { isProPlan, isEnterprisePlan } = useContext(SubscriptionContext) + + return isProPlan ? ( + + Cancel plan + + ) : isEnterprisePlan ? ( + + You have an Enterprise plan + + ) : ( + + Upgrade + + ) +} + +export function EnterprisePlanCTA() { + const { isProPlan } = useContext(SubscriptionContext) + + return isProPlan ? : +} + +const ActionBtnSC = styled(Button)({ + width: '100%', +}) diff --git a/www/src/components/account/billing/BillingPricingCard.tsx b/www/src/components/account/billing/BillingPricingCard.tsx index 56951efd5..cc4190399 100644 --- a/www/src/components/account/billing/BillingPricingCard.tsx +++ b/www/src/components/account/billing/BillingPricingCard.tsx @@ -67,7 +67,7 @@ function BillingPricingCard({
diff --git a/www/src/components/account/billing/BillingPricingCards.tsx b/www/src/components/account/billing/BillingPricingCards.tsx index 0d01ebdea..230b5dc15 100644 --- a/www/src/components/account/billing/BillingPricingCards.tsx +++ b/www/src/components/account/billing/BillingPricingCards.tsx @@ -1,119 +1,65 @@ -import { Button } from '@pluralsh/design-system' +import { Button, Card } from '@pluralsh/design-system' import { Flex } from 'honorable' -import { useCallback, useContext, useState } from 'react' -import { useSearchParams } from 'react-router-dom' +import { useContext } from 'react' -import SubscriptionContext from '../../../contexts/SubscriptionContext' +import styled from 'styled-components' + +import { ButtonProps } from '@pluralsh/design-system/dist/components/Button' -import BillingDowngradeModal from './BillingDowngradeModal' +import SubscriptionContext from '../../../contexts/SubscriptionContext' import BillingPricingCard from './BillingPricingCard' -import BillingStartTrialModal from './BillingStartTrialModal' -import BillingUpgradeToProfessionalModal from './BillingUpgradeToProfessionalModal' +import { EnterprisePlanCTA } from './BillingManagePlan' -function ContactUs({ primary }: { primary?: boolean }) { +export function ContactUs({ ...props }: ButtonProps) { return ( ) } -function CurrentPlanButton() { - return ( - - ) -} - -function BillingPricingCards() { - const [searchParams, setSearchParams] = useSearchParams() +function BillingPricingCards({ + onCancel, + onUpgrade, +}: { + onCancel: () => void + onUpgrade: () => void +}) { const { isProPlan, isEnterprisePlan } = useContext(SubscriptionContext) - const [downgradeModalOpen, setDowngradeModalOpen] = useState(false) - - const upgradeToProfessionalModalOpen = - typeof searchParams.get('upgrade') === 'string' - const setUpgradeToProfessionalModalOpen = useCallback( - (isOpen) => { - setSearchParams((sp) => { - if (isOpen) { - sp.set('upgrade', '1') - } else { - sp.delete('upgrade') - } - - return sp - }) - }, - [setSearchParams] - ) - - const trialModalOpen = typeof searchParams.get('trial') === 'string' - const setOpenTrialModal = useCallback( - (isOpen) => { - setSearchParams((params) => { - if (isOpen) { - params.set('trial', '1') - } else { - params.delete('trial') - } - - return params - }) - }, - [setSearchParams] - ) - return ( - <> - + + + - Free -
-
- - } + title="Pro Plan" + subtitle="Cost based on # of clusters" items={[ { - label: 'Free forever', - checked: true, - }, - { - label: 'Unlimited open-source apps', - checked: true, - }, - { - label: 'Up to 2 users', + label: '30 day free trial', checked: true, }, { - label: '1 cluster', + label: 'Up to 10 clusters', checked: true, }, { - label: 'OAuth integration', + label: 'Plural cloud hosting', checked: true, }, { - label: 'Community support', + label: '24 hour, 99.9% SLA uptime', checked: true, }, ]} @@ -122,79 +68,95 @@ function BillingPricingCards() { ) : isEnterprisePlan ? ( - + ) : ( - + ) } /> - Tailored -
-
- - } + title="Enterprise" + subtitle="Custom" items={[ { - label: 'Everything in Pro plan', + label: 'Pro plan perks', checked: false, }, { - label: '4 hour SLA', + label: 'Unlimited clusters', checked: true, }, { - label: 'Dedicated SRE', + label: 'Flexible hosting options', checked: true, }, { - label: 'SSO', + label: '1 hour SLA', checked: true, }, { - label: 'Commercial license', + label: 'Customized training', checked: true, }, { - label: 'Cost optimization', + label: 'Dedicated success team', checked: true, }, ]} - callToAction={ - isEnterprisePlan ? ( - - ) : isProPlan ? ( - - ) : ( - - ) - } + callToAction={} />
- setUpgradeToProfessionalModalOpen(false)} - /> - setDowngradeModalOpen(false)} - /> - setOpenTrialModal(false)} - /> - +
+ ) +} + +function CurrentPlanCard({ onCancel }: { onCancel: () => void }) { + const { subscription, isProPlan } = useContext(SubscriptionContext) + + return ( + + + You are currently on the{' '} + {subscription?.plan?.name ?? 'Free'} Plan + + {isProPlan && ( + + )} + ) } +const CurrentPlanCardSC = styled(Card)(({ theme }) => ({ + ...theme.partials.text.body1Bold, + padding: theme.spacing.medium, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + '& em': { + fontStyle: 'normal', + color: theme.colors['text-primary-accent'], + }, +})) export default BillingPricingCards diff --git a/www/src/components/account/billing/BillingPricingTable.tsx b/www/src/components/account/billing/BillingPricingTable.tsx index 70a6a9a79..7a6f33934 100644 --- a/www/src/components/account/billing/BillingPricingTable.tsx +++ b/www/src/components/account/billing/BillingPricingTable.tsx @@ -1,285 +1,171 @@ import { CheckIcon, CloseIcon } from '@pluralsh/design-system' -import { Div } from 'honorable' +import styled from 'styled-components' -const columnStyles = { - position: 'relative', - boxShadow: '-12px 0 12px 0px rgb(14 16 21 / 20%)', - '&:first-child': { - boxShadow: 'none', - '> div': { - display: 'flex', - justifyContent: 'flex-end', - textAlign: 'right', +import { ReactNode } from 'react' + +import { EnterprisePlanCTA, ProPlanCTA } from './BillingManagePlan' + +export default function BillingPricingTable({ + onUpgrade, + onCancel, +}: { + onUpgrade: () => void + onCancel: () => void +}) { + const plans = getPlans({ + proPlanCTA: ( + + ), + enterprisePlanCTA: , + }) + + return ( + + + + {plans.map((plan) => ( + + ))} + + + + + {plans.map((plan) => ( + {plan.name} + ))} + + + + {rows.map((rowLabel) => ( + + {rowLabel !== 'action' && rowLabel} + {plans.map((plan) => ( + {plan.values[rowLabel]} + ))} + + ))} + + + ) +} + +const CheckIconSC = styled(CheckIcon).attrs(() => ({ + color: 'icon-success', +}))`` + +const TableSC = styled.table(({ theme }) => ({ + tableLayout: 'fixed', + width: '100%', + background: theme.colors['fill-one'], + borderSpacing: 0, + borderRadius: theme.borderRadiuses.medium, + border: theme.borders.default, + '& th, & td': { + borderBottom: theme.borders.default, + borderRight: theme.borders.default, + // targets last cell in each row + '&:last-child': { + borderRight: 'none', }, }, - '> div': { - display: 'flex', - alignItems: 'center', - padding: '8px 16px', - height: 52, - borderTop: '1px solid border-fill-two', - }, - '> div:nth-child(even)': { - backgroundColor: 'fill-two', + // targets each cell in last row + '& tr:last-child td': { + borderBottom: 'none', }, - '> div:nth-child(odd)': { - backgroundColor: 'fill-one', - }, - '> div:first-child': { - height: 96, +})) + +const TableBodySC = styled.tbody(({ theme }) => ({ + '& tr:nth-child(odd)': { + backgroundColor: theme.colors['fill-one-selected'], }, -} +})) -const firstColumnCellProps = { - body2: true, - borderLeft: '1px solid border-fill-two', -} +const TableHeaderCellSC = styled.th(({ theme }) => ({ + ...theme.partials.text.subtitle2, + padding: `${theme.spacing.xlarge}px ${theme.spacing.large}px`, + textAlign: 'left', +})) -const lastColumnCellProps = { - borderRight: '1px solid border-fill-two', -} +const TableCellSC = styled.td(({ theme }) => ({ + ...theme.partials.text.body2, + padding: `${theme.spacing.medium}px ${theme.spacing.large}px`, + color: theme.colors['text-xlight'], + whiteSpace: 'pre-wrap', +})) -function BillingPricingTable() { - return ( -
-
-
-
- Open-Source Apps -
-
Clusters
-
Users
-
Services
-
Roles
-
Groups
-
Service accounts
-
Continuous deployment
-
Discord Forum
-
Community support
-
- Private Slack Connect to Plural engineers -
-
Dedicated support engineer
-
Onboarding
-
Emergency Hotfixes
-
SLAs
-
Coverage
-
Authentication
-
VPN
-
Audit logs
-
SOC 2
-
GDPR
-
Compliance reports
-
Training
-
Developer support
-
Commercial license
-
Cost optimization
-
- Invoices -
-
-
-
- Open-source -
-
Unlimited
-
Free
-
Up to 2
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
Best effort
-
Google OAuth + OIDC
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- Custom -
-
- Unlimited -
-
- Custom -
-
- Unlimited -
-
- Unlimited -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
4 hours
-
- Extended -
-
- SSO + Google OAuth + OIDC -
-
- -
-
- -
-
- -
-
- -
-
- -
-
Available
-
- -
-
- -
-
- -
-
- -
-
-
- ) -} +const rows = [ + 'Clusters', + 'Hosting', + 'SLA', + 'SLA Response', + 'Communication', + 'Success Team', + 'Customized Training', + 'Multi-cluster Deployment', + 'CD Pipelines', + 'IaC Management', + 'PR Automations', + 'Git Integrations', + 'Policy Management', + 'GDPR Compliant', + 'SOC 2 Compliant', + 'action', // not shown but used for bottom button +] -export default BillingPricingTable +const getPlans = ({ + proPlanCTA, + enterprisePlanCTA, +}: { + proPlanCTA: ReactNode + enterprisePlanCTA: ReactNode +}) => [ + { + name: 'Pro Plan', + values: { + Clusters: 'Up to 10', + Hosting: 'Plural shared infrastructure', + SLA: '99.9% uptime', + 'SLA Response': '1 business day response', + Communication: 'Email support', + 'Success Team': , + 'Customized Training': , + 'Multi-cluster Deployment': , + 'CD Pipelines': , + 'IaC Management': , + 'PR Automations': , + 'Git Integrations': , + 'Policy Management': , + 'GDPR Compliant': , + 'SOC 2 Compliant': , + action: proPlanCTA, + }, + }, + { + name: 'Enterprise', + values: { + Clusters: 'Unlimited', + Hosting: 'Shared, dedicated, or on-prem infrastructure', + SLA: '99.9% uptime', + 'SLA Response': '1 hour guaranteed response', + Communication: 'Dedicated Slack or Teams\nOn-demand Calls', + 'Success Team': , + 'Customized Training': , + 'Multi-cluster Deployment': , + 'CD Pipelines': , + 'IaC Management': , + 'PR Automations': , + 'Git Integrations': , + 'Policy Management': , + 'GDPR Compliant': , + 'SOC 2 Compliant': , + action: enterprisePlanCTA, + }, + }, +] diff --git a/www/src/components/account/billing/PaymentForm.tsx b/www/src/components/account/billing/PaymentForm.tsx index 8b2c5f29e..9f4040afb 100644 --- a/www/src/components/account/billing/PaymentForm.tsx +++ b/www/src/components/account/billing/PaymentForm.tsx @@ -182,6 +182,7 @@ function PaymentFormInner() { {formVariant === PaymentFormVariant.Upgrade && ( , '$variant'>) { minWidth="175px" left={sidebarWidth + 8} border="1px solid border" - // Fix incorrect borders due to mixed element types - {...{ - '&, &>*:first-of-type:not(:first-child) > div': { - borderTop: theme.borders['fill-two'], - }, - }} onClick={() => setIsMenuOpened(false)} >