mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Feat/plan upgrade improvements (#33804)
* upgrade dialog changes * plan upgrade modal * update dialog improvements * factor in customer balance * copy * add PH a/b test * fix ts * smol stuff * fixes * group compute fees * smol edits * Update SubscriptionPlanUpdateDialog.tsx * Update SubscriptionPlanUpdateDialog.tsx * partner billing * downgrade * margin * show pricing info + filter out long tweets * fix tooltip --------- Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>
This commit is contained in:
1 parent
20f5474457
commit
7c04aadfa9
4 files changed
+889
-210
No files matched your search
@@ -26,6 +26,8 @@ export const billingPartnerLabel = (billingPartner?: string) => {
|
||||
return 'Fly.io'
|
||||
case 'aws':
|
||||
return 'AWS'
|
||||
case 'vercel_marketplace':
|
||||
return 'Vercel'
|
||||
default:
|
||||
return billingPartner
|
||||
}
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ const PaymentMethodSelection = ({
|
||||
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
|
||||
<div className="flex items-center space-x-4 text-foreground-light">
|
||||
<AlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm">No saved payment methods</p>
|
||||
<p className="text-sm">No payment methods</p>
|
||||
</div>
|
||||
|
||||
<ButtonTooltip
|
||||
|
||||
+234
-209
@@ -26,7 +26,7 @@ import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { useFlag, usePHFlag } from 'hooks/ui/useFlag'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
@@ -36,6 +36,7 @@ import DowngradeModal from './DowngradeModal'
|
||||
import { EnterpriseCard } from './EnterpriseCard'
|
||||
import ExitSurveyModal from './ExitSurveyModal'
|
||||
import MembersExceedLimitModal from './MembersExceedLimitModal'
|
||||
import SubscriptionPlanUpdateDialog from './SubscriptionPlanUpdateDialog'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
import UpgradeSurveyModal from './UpgradeModal'
|
||||
|
||||
@@ -48,6 +49,7 @@ const PlanUpdateSidePanel = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const originalPlanRef = useRef<string>()
|
||||
const allowOrioleDB = useFlag('allowOrioleDb')
|
||||
const newPlanUpdateDialog = usePHFlag('newPlanUpdateDialog')
|
||||
|
||||
const [showExitSurvey, setShowExitSurvey] = useState(false)
|
||||
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
|
||||
@@ -100,7 +102,6 @@ const PlanUpdateSidePanel = () => {
|
||||
|
||||
const billingViaPartner = subscription?.billing_via_partner === true
|
||||
const billingPartner = subscription?.billing_partner
|
||||
const paymentViaInvoice = subscription?.payment_method_type === 'invoice'
|
||||
|
||||
const {
|
||||
data: subscriptionPreview,
|
||||
@@ -115,14 +116,6 @@ const PlanUpdateSidePanel = () => {
|
||||
orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0
|
||||
const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier)
|
||||
|
||||
const expandUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded([...usageFeesExpanded, fee])
|
||||
}
|
||||
|
||||
const collapseUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedTier(undefined)
|
||||
@@ -159,10 +152,14 @@ const PlanUpdateSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const planMeta = selectedTier
|
||||
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
|
||||
: null
|
||||
|
||||
const onUpdateSubscription = async () => {
|
||||
if (!slug) return console.error('org slug is required')
|
||||
if (!selectedTier) return console.error('Selected plan is required')
|
||||
if (!selectedPaymentMethod && !paymentViaInvoice) {
|
||||
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
|
||||
return toast.error('Please select a payment method')
|
||||
}
|
||||
|
||||
@@ -189,9 +186,13 @@ const PlanUpdateSidePanel = () => {
|
||||
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
|
||||
}
|
||||
|
||||
const planMeta = selectedTier
|
||||
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
|
||||
: null
|
||||
const expandUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded([...usageFeesExpanded, fee])
|
||||
}
|
||||
|
||||
const collapseUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -356,218 +357,242 @@ const PlanUpdateSidePanel = () => {
|
||||
projects={orgProjects}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
loading={isUpdating}
|
||||
alignFooter="right"
|
||||
size="xlarge"
|
||||
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onCancel={() => setSelectedTier(undefined)}
|
||||
onConfirm={onUpdateSubscription}
|
||||
dialogOverlayProps={{ className: 'pointer-events-none' }}
|
||||
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
|
||||
>
|
||||
<Modal.Content>
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError
|
||||
error={subscriptionPreviewError}
|
||||
subject="Failed to preview subscription."
|
||||
/>
|
||||
)}
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<div>
|
||||
<Table
|
||||
className="mt-2"
|
||||
borderless={true}
|
||||
head={[
|
||||
<Table.th key="header-item">Item</Table.th>,
|
||||
<Table.th key="header-count" className="text-right pr-4">
|
||||
Usage
|
||||
</Table.th>,
|
||||
<Table.th key="header-unit-price">Unit Price</Table.th>,
|
||||
<Table.th key="header-price" className="text-right">
|
||||
Cost
|
||||
</Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{subscriptionPreview.breakdown.map((item) => (
|
||||
<>
|
||||
<Table.tr key={item.description}>
|
||||
<Table.td>
|
||||
{item.breakdown && item.breakdown.length > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="!pl-0 !pr-1"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'transition',
|
||||
usageFeesExpanded.includes(item.description) && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
usageFeesExpanded.includes(item.description)
|
||||
? collapseUsageFee(item.description)
|
||||
: expandUsageFee(item.description)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.description ?? 'Unknown'}{' '}
|
||||
{item.description.endsWith('Compute') && ` (Hours)`}
|
||||
</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{item.quantity?.toLocaleString()}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{item.unit_price_desc
|
||||
? item.unit_price_desc
|
||||
: item.unit_price === 0
|
||||
? 'FREE'
|
||||
: item.unit_price
|
||||
? `${formatCurrency(item.unit_price)}`
|
||||
: ''}
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
{formatCurrency(item.total_price)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
{newPlanUpdateDialog === 'variant-a' ? (
|
||||
<SubscriptionPlanUpdateDialog
|
||||
selectedTier={selectedTier}
|
||||
selectedOrganization={selectedOrganization}
|
||||
onClose={() => setSelectedTier(undefined)}
|
||||
subscriptionPlanMeta={subscriptionPlanMeta}
|
||||
planMeta={planMeta}
|
||||
subscriptionPreviewError={subscriptionPreviewError}
|
||||
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
|
||||
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
billingViaPartner={billingViaPartner}
|
||||
billingPartner={billingPartner}
|
||||
subscription={subscription}
|
||||
slug={slug}
|
||||
currentPlanMeta={{
|
||||
...availablePlans.find((p) => p.id === subscription?.plan?.id),
|
||||
features:
|
||||
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
|
||||
?.features || [],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Modal
|
||||
loading={isUpdating}
|
||||
alignFooter="right"
|
||||
size="xlarge"
|
||||
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onCancel={() => setSelectedTier(undefined)}
|
||||
onConfirm={onUpdateSubscription}
|
||||
dialogOverlayProps={{ className: 'pointer-events-none' }}
|
||||
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
|
||||
>
|
||||
<Modal.Content>
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError
|
||||
error={subscriptionPreviewError}
|
||||
subject="Failed to preview subscription."
|
||||
/>
|
||||
)}
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<div>
|
||||
<Table
|
||||
className="mt-2"
|
||||
borderless={true}
|
||||
head={[
|
||||
<Table.th key="header-item">Item</Table.th>,
|
||||
<Table.th key="header-count" className="text-right pr-4">
|
||||
Usage
|
||||
</Table.th>,
|
||||
<Table.th key="header-unit-price">Unit Price</Table.th>,
|
||||
<Table.th key="header-price" className="text-right">
|
||||
Cost
|
||||
</Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{subscriptionPreview.breakdown.map((item) => (
|
||||
<>
|
||||
<Table.tr key={item.description}>
|
||||
<Table.td>
|
||||
{item.breakdown && item.breakdown.length > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="!pl-0 !pr-1"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'transition',
|
||||
usageFeesExpanded.includes(item.description) && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
usageFeesExpanded.includes(item.description)
|
||||
? collapseUsageFee(item.description)
|
||||
: expandUsageFee(item.description)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.description ?? 'Unknown'}{' '}
|
||||
{item.description.endsWith('Compute') && ` (Hours)`}
|
||||
</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{item.quantity?.toLocaleString()}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{item.unit_price_desc
|
||||
? item.unit_price_desc
|
||||
: item.unit_price === 0
|
||||
? 'FREE'
|
||||
: item.unit_price
|
||||
? `${formatCurrency(item.unit_price)}`
|
||||
: ''}
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
{formatCurrency(item.total_price)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
|
||||
{usageFeesExpanded.includes(item.description) &&
|
||||
item.breakdown &&
|
||||
item.breakdown.length > 0 &&
|
||||
item.breakdown.map((project) => (
|
||||
<Table.tr key={project.project_ref}>
|
||||
<Table.td className="!pl-12">{project.project_name}</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{project.usage}
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
{usageFeesExpanded.includes(item.description) &&
|
||||
item.breakdown &&
|
||||
item.breakdown.length > 0 &&
|
||||
item.breakdown.map((project) => (
|
||||
<Table.tr key={project.project_ref}>
|
||||
<Table.td className="!pl-12">{project.project_name}</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{project.usage}
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
|
||||
<Table.tr>
|
||||
<Table.td className="font-medium">
|
||||
Monthly Costs (excluding over-usage and credits)
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
<Table.td className="text-right font-medium">
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview.breakdown.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
)
|
||||
) ?? 0
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</>
|
||||
}
|
||||
></Table>
|
||||
<Table.tr>
|
||||
<Table.td className="font-medium">
|
||||
Monthly Costs (excluding over-usage and credits)
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
<Table.td className="text-right font-medium">
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview.breakdown.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
)
|
||||
) ?? 0
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</>
|
||||
}
|
||||
></Table>
|
||||
|
||||
<InformationBox
|
||||
className="mt-4"
|
||||
title="Usage-billing for Compute"
|
||||
icon={<Info />}
|
||||
defaultVisibility={true}
|
||||
hideCollapse={true}
|
||||
description={
|
||||
<div>
|
||||
<p className="text-sm mt-2">
|
||||
Each project is a dedicated server and database. Paid plans come with $10 of
|
||||
Compute Credits to cover one project on the default Micro Compute size or
|
||||
parts of any compute addon. Additional unpaused projects on paid plans will
|
||||
incur compute usage costs starting at $10 per month, billed hourly.
|
||||
</p>
|
||||
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<InformationBox
|
||||
className="mt-4"
|
||||
title="Usage-billing for Compute"
|
||||
icon={<Info />}
|
||||
defaultVisibility={true}
|
||||
hideCollapse={true}
|
||||
description={
|
||||
<div>
|
||||
<p className="text-sm mt-2">
|
||||
Mixing paid and non-paid projects in a single organization is not possible.
|
||||
If you want projects to be on the Free Plan, use self-serve project
|
||||
transfers.
|
||||
Each project is a dedicated server and database. Paid plans come with $10 of
|
||||
Compute Credits to cover one project on the default Micro Compute size or
|
||||
parts of any compute addon. Additional unpaused projects on paid plans will
|
||||
incur compute usage costs starting at $10 per month, billed hourly.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-x-3 mt-2">
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How billing for Compute works
|
||||
</Link>
|
||||
</Button>
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<p className="text-sm mt-2">
|
||||
Mixing paid and non-paid projects in a single organization is not
|
||||
possible. If you want projects to be on the Free Plan, use self-serve
|
||||
project transfers.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-x-3 mt-2">
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/project-transfer"
|
||||
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Project transfers
|
||||
How billing for Compute works
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/project-transfer"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Project transfers
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Content>
|
||||
{!billingViaPartner ? (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
Upon clicking confirm, your monthly invoice will be adjusted and your credit card
|
||||
will be charged immediately. Changing the plan resets your billing cycle and may
|
||||
result in a prorated charge for previous usage.
|
||||
</p>
|
||||
|
||||
<div className="!mt-4">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Content>
|
||||
{!billingViaPartner ? (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
Upon clicking confirm, your monthly invoice will be adjusted and your credit card
|
||||
will be charged immediately. Changing the plan resets your billing cycle and may
|
||||
result in a prorated charge for previous usage.
|
||||
</p>
|
||||
|
||||
<div className="!mt-4">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<MembersExceedLimitModal
|
||||
visible={showDowngradeError}
|
||||
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { InfoIcon, Check, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import tweets from 'shared-data/tweets'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Card,
|
||||
CardContent,
|
||||
Badge,
|
||||
} from 'ui'
|
||||
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
|
||||
import { organizationKeys } from 'data/organizations/keys'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { SubscriptionTier } from 'data/subscriptions/types'
|
||||
import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
import { Button, Dialog, DialogContent } from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview'
|
||||
|
||||
const getRandomTweet = () => {
|
||||
const filteredTweets = tweets.filter((it) => it.text.length < 180)
|
||||
const randomIndex = Math.floor(Math.random() * filteredTweets.length)
|
||||
return filteredTweets[randomIndex]
|
||||
}
|
||||
|
||||
const PLAN_HEADINGS = {
|
||||
tier_pro:
|
||||
'the Pro plan and create unlimited projects, daily backups and premium support when you need it',
|
||||
tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention',
|
||||
default: 'to a new plan',
|
||||
} as const
|
||||
|
||||
type PlanHeadingKey = keyof typeof PLAN_HEADINGS
|
||||
|
||||
// Add downgrade headings
|
||||
const DOWNGRADE_PLAN_HEADINGS = {
|
||||
tier_free: 'the Free plan with limited resources and active projects',
|
||||
tier_pro: 'the Pro plan',
|
||||
default: 'to a lower plan',
|
||||
} as const
|
||||
|
||||
type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS
|
||||
|
||||
interface Props {
|
||||
selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined
|
||||
onClose: () => void
|
||||
subscriptionPlanMeta: any
|
||||
planMeta: any
|
||||
subscriptionPreviewError: any
|
||||
subscriptionPreviewIsLoading: boolean
|
||||
subscriptionPreviewInitialized: boolean
|
||||
subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined
|
||||
billingViaPartner: boolean
|
||||
billingPartner?: string
|
||||
selectedOrganization: any
|
||||
subscription: any
|
||||
slug?: string
|
||||
currentPlanMeta: any
|
||||
}
|
||||
|
||||
const SubscriptionPlanUpdateDialog = ({
|
||||
selectedTier,
|
||||
onClose,
|
||||
subscriptionPlanMeta,
|
||||
planMeta,
|
||||
subscriptionPreviewError,
|
||||
subscriptionPreviewIsLoading,
|
||||
subscriptionPreviewInitialized,
|
||||
subscriptionPreview,
|
||||
billingViaPartner,
|
||||
billingPartner,
|
||||
selectedOrganization,
|
||||
subscription,
|
||||
slug,
|
||||
currentPlanMeta,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
|
||||
const [testimonialTweet, setTestimonialTweet] = useState(getRandomTweet())
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTier !== undefined && selectedTier !== 'tier_free') {
|
||||
setTestimonialTweet(getRandomTweet())
|
||||
}
|
||||
}, [selectedTier])
|
||||
|
||||
const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation(
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`Successfully ${planMeta?.change_type === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!`
|
||||
)
|
||||
onClose()
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Unable to update subscription: ${error.message}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const onUpdateSubscription = async () => {
|
||||
if (!slug) return console.error('org slug is required')
|
||||
if (!selectedTier) return console.error('Selected plan is required')
|
||||
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
|
||||
return toast.error('Please select a payment method')
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod) {
|
||||
queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
defaultPaymentMethodId: selectedPaymentMethod,
|
||||
data: prev.data.map((pm: any) => ({
|
||||
...pm,
|
||||
is_default: pm.id === selectedPaymentMethod,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If the user is downgrading from team, should have spend cap disabled by default
|
||||
const tier =
|
||||
subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO
|
||||
? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier)
|
||||
: selectedTier
|
||||
|
||||
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
|
||||
}
|
||||
|
||||
const features = subscriptionPlanMeta?.features?.[0]?.features || []
|
||||
const topFeatures = features
|
||||
|
||||
// Get current plan features for downgrade comparison
|
||||
const currentPlanFeatures = currentPlanMeta?.features?.[0]?.features || []
|
||||
|
||||
// Features that will be lost when downgrading
|
||||
const featuresToLose =
|
||||
planMeta?.change_type === 'downgrade'
|
||||
? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => {
|
||||
const featureStr = typeof feature === 'string' ? feature : feature[0]
|
||||
// Check if this feature exists in the new plan
|
||||
return !topFeatures.some((newFeature: string | [string, ...any[]]) => {
|
||||
const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0]
|
||||
return newFeatureStr === featureStr
|
||||
})
|
||||
})
|
||||
: []
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
size="xlarge"
|
||||
className="p-0"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 h-full items-stretch">
|
||||
{/* Left Column */}
|
||||
<div className="p-8 pb-8 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
|
||||
{selectedOrganization?.name} to{' '}
|
||||
{planMeta?.change_type === 'downgrade'
|
||||
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
|
||||
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
|
||||
</h3>
|
||||
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2 mb-4 mt-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<>
|
||||
<Table className="mt-2 mb-4 text-foreground-light">
|
||||
<TableBody>
|
||||
{(() => {
|
||||
// Calculate remaining days in current billing cycle
|
||||
const now = Math.floor(Date.now() / 1000) // current time in seconds
|
||||
const remainingSeconds = subscription?.current_period_end - now
|
||||
const totalSeconds =
|
||||
subscription?.current_period_end - subscription?.current_period_start
|
||||
const remainingRatio = remainingSeconds / totalSeconds
|
||||
|
||||
// Calculate prorated credit for current plan
|
||||
const currentPlanMonthlyPrice = currentPlanMeta?.price ?? 0
|
||||
const proratedCredit = currentPlanMonthlyPrice * remainingRatio
|
||||
|
||||
// Calculate new plan cost
|
||||
const newPlanCost = subscriptionPlanMeta?.priceMonthly ?? 0
|
||||
|
||||
const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1
|
||||
|
||||
// Calculate total charge (new plan - prorated credit)
|
||||
const totalCharge = Math.max(
|
||||
0,
|
||||
newPlanCost - proratedCredit - customerBalance
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>{subscriptionPlanMeta?.name} Plan</span>
|
||||
<Badge variant={'brand'} size={'small'} className="ml-1">
|
||||
New
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
{formatCurrency(newPlanCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{subscription?.plan?.id !== 'free' && (
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Unused Time on {subscription?.plan?.name} Plan</span>
|
||||
<InfoTooltip className="max-w-sm">
|
||||
Your previous plan was charged upfront, so a plan change will
|
||||
prorate any unused time in credits. If the prorated credits
|
||||
exceed the new plan charge, the excessive credits are added to
|
||||
your organization for future use.
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
-{formatCurrency(proratedCredit)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* Ignore rare case with negative balance (debt) */}
|
||||
{customerBalance > 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Credits</span>
|
||||
<InfoTooltip>
|
||||
Credits will be used first before charging your card.
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
{formatCurrency(customerBalance)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow className="text-foreground">
|
||||
<TableCell className="py-2 pl-0 border-t">Charge today</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right border-t">
|
||||
{formatCurrency(totalCharge)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{subscription?.plan?.id !== 'free' && (
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>+ current cycle usage</span>
|
||||
<InfoTooltip className="max-w-sm">
|
||||
Changing your plan resets your billing cycle. If your previous
|
||||
billing cycle still has any outstanding usage charges, they will
|
||||
be added to your total charge today.
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
<Link
|
||||
href={`/org/${slug}/billing#breakdown`}
|
||||
className="text-sm text-brand hover:text-brand-600 transition"
|
||||
target="_blank"
|
||||
>
|
||||
View spend
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Card className="cursor-help text-sm">
|
||||
<CardContent className="flex items-center gap-2 py-2 px-3 text-foreground-light hover:text-foreground">
|
||||
<InfoIcon strokeWidth={1.5} size={16} className="text-foreground-light" />
|
||||
Monthly invoice estimate is{' '}
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview?.breakdown.reduce(
|
||||
(prev: number, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-[520px] p-6">
|
||||
<h3 className="text-md font-medium mb-2">Your new monthly invoice</h3>
|
||||
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError
|
||||
error={subscriptionPreviewError}
|
||||
subject="Failed to preview subscription."
|
||||
/>
|
||||
)}
|
||||
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2 p-6">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscriptionPreviewInitialized && (
|
||||
<>
|
||||
<Table className="[&_tr:last-child]:border-t font-mono text-xs">
|
||||
<TableBody>
|
||||
{/* Non-compute items and Projects list */}
|
||||
{(() => {
|
||||
// Combine all compute-related projects
|
||||
const computeItems =
|
||||
subscriptionPreview?.breakdown?.filter(
|
||||
(item) =>
|
||||
item.description?.toLowerCase().includes('compute') &&
|
||||
item.breakdown?.length > 0
|
||||
) || []
|
||||
|
||||
const computeCreditsItem =
|
||||
subscriptionPreview?.breakdown?.find((item) =>
|
||||
item.description.startsWith('Compute Credits')
|
||||
) ?? null
|
||||
|
||||
const planItem = subscriptionPreview?.breakdown?.find((item) =>
|
||||
item.description?.toLowerCase().includes('plan')
|
||||
)
|
||||
|
||||
const allProjects = computeItems.flatMap((item) =>
|
||||
item.breakdown.map((project) => ({
|
||||
...project,
|
||||
computeType: item.description,
|
||||
computeCosts: Math.round(
|
||||
item.total_price / item.breakdown.length
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
const otherItems =
|
||||
subscriptionPreview?.breakdown?.filter(
|
||||
(item) =>
|
||||
!item.description?.toLowerCase().includes('compute') &&
|
||||
!item.description?.toLowerCase().includes('plan')
|
||||
) || []
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Combined projects section */}
|
||||
{allProjects.length > 0 && (
|
||||
<>
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="!py-2 px-0">
|
||||
{planItem?.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-0">
|
||||
{formatCurrency(planItem?.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="!py-2 px-0 flex items-center gap-1">
|
||||
<span>Compute</span>
|
||||
<InfoTooltip className="max-w-sm p-3">
|
||||
<p className="prose text-xs mb-2">
|
||||
Each project on a paid plan is a dedicated server
|
||||
running 24/7 with no pausing. The first project is
|
||||
covered by Compute Credits. Additional projects will
|
||||
incur compute costs starting at $10/month,
|
||||
independent of activity. See{' '}
|
||||
<Link
|
||||
href={
|
||||
'/docs/guides/platform/manage-your-usage/compute'
|
||||
}
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<>
|
||||
<p className="text-xs text-foreground-light mb-3">
|
||||
Mixing paid and non-paid projects in a single
|
||||
organization is not possible. If you want
|
||||
projects to be on the Free Plan, use self-serve
|
||||
project transfers.
|
||||
</p>
|
||||
<div className="space-x-3">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
href="/docs/guides/platform/manage-your-usage/compute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How billing for Compute works
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
href="/docs/guides/platform/project-transfer"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Project transfers
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-0">
|
||||
{formatCurrency(
|
||||
computeItems.reduce(
|
||||
(sum: number, item) => sum + item.total_price,
|
||||
0
|
||||
) + (computeCreditsItem?.total_price ?? 0)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* Show first 3 projects */}
|
||||
{allProjects.map((project) => (
|
||||
<TableRow
|
||||
key={project.project_ref}
|
||||
className="text-foreground-light"
|
||||
>
|
||||
<TableCell className="!py-2 px-0 pl-6">
|
||||
{project.project_name} ({project.computeType}) |{' '}
|
||||
{formatCurrency(project.computeCosts)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{computeCreditsItem && (
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="!py-2 px-0 pl-6">
|
||||
Compute Credits |{' '}
|
||||
{formatCurrency(computeCreditsItem.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Non-compute items */}
|
||||
{otherItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.description}
|
||||
className="text-foreground-light"
|
||||
>
|
||||
<TableCell className="text-xs py-2 px-0">
|
||||
<div className="flex items-center gap-1 flex items-center gap-1">
|
||||
<span>{item.description ?? 'Unknown'}</span>
|
||||
{item.breakdown.length > 0 && (
|
||||
<InfoTooltip className="max-w-sm">
|
||||
<p>Projects using {item.description}:</p>
|
||||
<ul className="ml-6 list-disc">
|
||||
{item.breakdown.map((breakdown) => (
|
||||
<li
|
||||
key={`${item.description}-breakdown-${breakdown.project_ref}`}
|
||||
>
|
||||
{breakdown.project_name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</InfoTooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs py-2 px-0">
|
||||
{formatCurrency(item.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
return content
|
||||
})()}
|
||||
|
||||
<TableRow>
|
||||
<TableCell className="font-medium py-2 px-0">
|
||||
Total per month (excluding other usage)
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium py-2 px-0">
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview?.breakdown?.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0
|
||||
) ?? 0
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4">
|
||||
{!billingViaPartner ? (
|
||||
<div className="space-y-4">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing
|
||||
cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex space-x-4">
|
||||
<Button type="default" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isUpdating}
|
||||
type="primary"
|
||||
onClick={onUpdateSubscription}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm {planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="bg-surface-100 p-8 flex flex-col border-l">
|
||||
{planMeta?.change_type === 'downgrade'
|
||||
? featuresToLose.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm mb-1">Features you'll lose</h3>
|
||||
<p className="text-xs text-foreground-light mb-4">
|
||||
Please review this carefully before downgrading.
|
||||
</p>
|
||||
<div className="space-y-2 mb-4 text-foreground-light">
|
||||
{featuresToLose.map((feature: string | [string, ...any[]]) => (
|
||||
<div
|
||||
key={typeof feature === 'string' ? feature : feature[0]}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="w-4">
|
||||
<InfoIcon className="h-3 w-3 text-amber-900" strokeWidth={3} />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{typeof feature === 'string' ? feature : feature[0]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: topFeatures.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm mb-4">Upgrade features</h3>
|
||||
|
||||
<div className="space-y-2 mb-4 text-foreground-light">
|
||||
{topFeatures.map((feature: string | [string, ...any[]]) => (
|
||||
<div
|
||||
key={typeof feature === 'string' ? feature : feature[0]}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="w-4">
|
||||
<Check className="h-3 w-3 text-brand" strokeWidth={3} />
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p>{typeof feature === 'string' ? feature : feature[0]}</p>
|
||||
{Array.isArray(feature) && feature.length > 1 && (
|
||||
<p className="text-foreground-lighter text-xs">{feature[1]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{planMeta?.change_type !== 'downgrade' && (
|
||||
<div className="border-t pt-6">
|
||||
<blockquote className="text-sm text-foreground-light italic">
|
||||
{testimonialTweet.text}
|
||||
<div className="mt-2 text-foreground">— @{testimonialTweet.handle}</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionPlanUpdateDialog
|
||||
Reference in new issue
Block a user