mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Refactor Invoice estimate tooltip in plan update confirmation dialog (#45446)
## Context Main fix is to ensure that the tooltip here is scrollable - but also adding some refactors This is the org billing page when downgrading an org ### Before <img width="400" alt="image" src="https://github.com/user-attachments/assets/6094c2e6-c1bb-460f-a2d2-347c1d7d2e77" /> ### After <img width="400" alt="image" src="https://github.com/user-attachments/assets/9d0ac9a6-6e89-4758-af14-8144a8a86469" /> ## Changes involved - Use HoverCard for invoice estimate in plan confirmation dialog - Also nudge the UI a little, e.g use a separate column for the compute prices + adjust text color to improve clarity - Refactor usage of `any` for some of the TS declarations <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an invoice estimate tooltip in subscription settings showing monthly charges with plan fees, combined compute rows, per-project compute costs, optional compute credits, and a total monthly estimate. * **Refactor** * Simplified the plan update flow by consolidating subscription preview handling and extracting the invoice UI into the new tooltip component. * **Chores** * Improved internal type definitions for subscription preview data and pricing tier identifiers. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
22e3ffc246
commit
aab3924eef
5 files changed
+279
-240
No files matched your search
+231
@@ -0,0 +1,231 @@
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
cn,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { formatCurrency } from '@/lib/helpers'
|
||||
|
||||
const CELL_CLASSNAME = 'py-2 px-0'
|
||||
|
||||
interface InvoiceEstimateTooltipProps {
|
||||
subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult
|
||||
}
|
||||
|
||||
export const InvoiceEstimateTooltip = ({
|
||||
subscriptionPreviewQueryResult,
|
||||
}: InvoiceEstimateTooltipProps) => {
|
||||
const {
|
||||
data: subscriptionPreview,
|
||||
error: subscriptionPreviewError,
|
||||
isPending: subscriptionPreviewIsLoading,
|
||||
isSuccess: subscriptionPreviewInitialized,
|
||||
} = subscriptionPreviewQueryResult
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50} closeDelay={50}>
|
||||
<HoverCardTrigger>
|
||||
<HelpCircle size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-[400px] -translate-y-6">
|
||||
<h4 className="font-medium">Your new monthly invoice</h4>
|
||||
<p className="prose text-xs mb-2 text-balance">
|
||||
First project included. Additional projects cost <span translate="no">$10</span>+/month
|
||||
regardless of activity.{' '}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`}
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError error={subscriptionPreviewError} subject="Failed to preview subscription." />
|
||||
)}
|
||||
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscriptionPreviewInitialized && (
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<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 &&
|
||||
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 = (
|
||||
<>
|
||||
{planItem && (
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className={CELL_CLASSNAME}>{planItem.description}</TableCell>
|
||||
<TableCell
|
||||
className={cn(CELL_CLASSNAME, 'text-foreground text-right')}
|
||||
translate="no"
|
||||
>
|
||||
{formatCurrency(planItem.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* Combined projects section */}
|
||||
{allProjects.length > 0 && (
|
||||
<>
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className={cn(CELL_CLASSNAME)}>
|
||||
<span>Compute</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
translate="no"
|
||||
className={cn(CELL_CLASSNAME, 'text-foreground text-right')}
|
||||
>
|
||||
{formatCurrency(
|
||||
computeItems.reduce(
|
||||
(sum: number, item) => sum + item.total_price,
|
||||
0
|
||||
) + (computeCreditsItem?.total_price ?? 0)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{allProjects.map((project) => (
|
||||
<TableRow key={project.project_ref} className="text-foreground-lighter">
|
||||
<TableCell translate="no" className={cn(CELL_CLASSNAME, 'pl-6')}>
|
||||
<p
|
||||
title={`${project.project_name} (${project.computeType})`}
|
||||
className="truncate max-w-64"
|
||||
>
|
||||
{project.project_name} ({project.computeType})
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
translate="no"
|
||||
className={cn(CELL_CLASSNAME, 'text-right')}
|
||||
>
|
||||
{formatCurrency(project.computeCosts)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{computeCreditsItem && (
|
||||
<TableRow className="text-foreground-lighter">
|
||||
<TableCell translate="no" className={cn(CELL_CLASSNAME, 'pl-6')}>
|
||||
Compute Credits
|
||||
</TableCell>
|
||||
<TableCell
|
||||
translate="no"
|
||||
className={cn(CELL_CLASSNAME, 'text-right')}
|
||||
>
|
||||
{formatCurrency(computeCreditsItem.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Non-compute items */}
|
||||
{otherItems.map((item) => (
|
||||
<TableRow key={item.description} className="text-foreground-light">
|
||||
<TableCell className={cn(CELL_CLASSNAME, 'text-xs')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{item.description ?? 'Unknown'}</span>
|
||||
{item.breakdown && 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
|
||||
translate="no"
|
||||
className={cn(CELL_CLASSNAME, 'text-foreground text-right text-xs')}
|
||||
>
|
||||
{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" translate="no">
|
||||
{formatCurrency(
|
||||
subscriptionPreview?.breakdown?.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
+14
-22
@@ -135,13 +135,7 @@ export const PlanUpdateSidePanel = () => {
|
||||
{ enabled: visible }
|
||||
)
|
||||
|
||||
const {
|
||||
data: subscriptionPreview,
|
||||
error: subscriptionPreviewError,
|
||||
isPending: subscriptionPreviewIsLoading,
|
||||
isFetching: subscriptionPreviewIsFetching,
|
||||
isSuccess: subscriptionPreviewInitialized,
|
||||
} = useOrganizationBillingSubscriptionPreview({
|
||||
const subscriptionPreviewData = useOrganizationBillingSubscriptionPreview({
|
||||
tier: selectedTier,
|
||||
organizationSlug: slug,
|
||||
address: debouncedAddress,
|
||||
@@ -181,10 +175,10 @@ export const PlanUpdateSidePanel = () => {
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && isSuccessSubscription) {
|
||||
if (visible && isSuccessSubscription && subscription.plan.id) {
|
||||
originalPlanRef.current = subscription.plan.id
|
||||
}
|
||||
}, [visible, isSuccessSubscription])
|
||||
}, [visible, isSuccessSubscription, subscription?.plan.id])
|
||||
|
||||
const onConfirmDowngrade = () => {
|
||||
setSelectedTier(undefined)
|
||||
@@ -198,6 +192,14 @@ export const PlanUpdateSidePanel = () => {
|
||||
const planMeta = selectedTier
|
||||
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
|
||||
: null
|
||||
|
||||
const currentPlanMeta = {
|
||||
...availablePlans.find((p) => p.id === subscription?.plan?.id),
|
||||
features:
|
||||
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)?.features ||
|
||||
[],
|
||||
}
|
||||
|
||||
const stripeProjectsUpgradeCommand = getStripeProjectsUpgradeCommand(
|
||||
selectedOrganization?.plan?.id ?? subscription?.plan?.id
|
||||
)
|
||||
@@ -321,7 +323,7 @@ export const PlanUpdateSidePanel = () => {
|
||||
hasOrioleProjects
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedTier(plan.id as any)
|
||||
setSelectedTier(plan.id as 'tier_free' | 'tier_pro' | 'tier_team')
|
||||
sendEvent({
|
||||
action: 'studio_pricing_plan_cta_clicked',
|
||||
properties: {
|
||||
@@ -403,19 +405,9 @@ export const PlanUpdateSidePanel = () => {
|
||||
selectedTier={selectedTier}
|
||||
onClose={() => setSelectedTier(undefined)}
|
||||
planMeta={planMeta}
|
||||
subscriptionPreviewError={subscriptionPreviewError}
|
||||
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
|
||||
subscriptionPreviewIsFetching={subscriptionPreviewIsFetching}
|
||||
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
subscription={subscription}
|
||||
subscriptionPreviewQueryResult={subscriptionPreviewData}
|
||||
projects={orgProjects}
|
||||
currentPlanMeta={{
|
||||
...availablePlans.find((p) => p.id === subscription?.plan?.id),
|
||||
features:
|
||||
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
|
||||
?.features || [],
|
||||
}}
|
||||
currentPlanMeta={currentPlanMeta}
|
||||
onAddressChange={handleAddressChange}
|
||||
onTaxIdChange={handleTaxIdChange}
|
||||
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
|
||||
|
||||
+27
-216
@@ -1,16 +1,18 @@
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { loadStripe, PaymentIntentResult, StripeElementsOptions } from '@stripe/stripe-js'
|
||||
import { useParams } from 'common'
|
||||
import { Check, InfoIcon } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
|
||||
import { Button, cn, Dialog, DialogContent } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { InvoiceEstimateTooltip } from './InvoiceEstimateTooltip'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
|
||||
import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation'
|
||||
@@ -19,13 +21,13 @@ import {
|
||||
billingPartnerLabel,
|
||||
getPlanChangeType,
|
||||
} from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { OrganizationBillingSubscriptionPreviewData } from '@/data/organizations/organization-billing-subscription-preview'
|
||||
import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview'
|
||||
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
|
||||
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
|
||||
import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change'
|
||||
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
|
||||
import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
|
||||
import { SubscriptionTier } from '@/data/subscriptions/types'
|
||||
import { OrgPlan, SubscriptionTier } from '@/data/subscriptions/types'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
DOCS_URL,
|
||||
@@ -62,14 +64,9 @@ type BreakdownItem =
|
||||
interface Props {
|
||||
selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined
|
||||
onClose: () => void
|
||||
planMeta: any
|
||||
subscriptionPreviewError: any
|
||||
subscriptionPreviewIsLoading: boolean
|
||||
subscriptionPreviewIsFetching: boolean
|
||||
subscriptionPreviewInitialized: boolean
|
||||
subscriptionPreview: OrganizationBillingSubscriptionPreviewData | undefined
|
||||
subscription: any
|
||||
currentPlanMeta: any
|
||||
planMeta?: OrgPlan | null
|
||||
currentPlanMeta?: Partial<OrgPlan> & { features: (string | string[])[] }
|
||||
subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult
|
||||
projects: OrgProject[]
|
||||
onAddressChange?: (address: CustomerAddress) => void
|
||||
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
|
||||
@@ -81,12 +78,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
selectedTier,
|
||||
onClose,
|
||||
planMeta,
|
||||
subscriptionPreviewError,
|
||||
subscriptionPreviewIsLoading,
|
||||
subscriptionPreviewIsFetching,
|
||||
subscriptionPreviewInitialized,
|
||||
subscriptionPreview,
|
||||
subscription,
|
||||
subscriptionPreviewQueryResult,
|
||||
currentPlanMeta,
|
||||
projects,
|
||||
onAddressChange,
|
||||
@@ -94,6 +86,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
useAsDefaultBillingAddress,
|
||||
onUseAsDefaultBillingAddressChange,
|
||||
}: Props) => {
|
||||
const { slug } = useParams()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
|
||||
@@ -104,6 +97,16 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
validateBillingProfile: () => Promise<boolean>
|
||||
}>(null)
|
||||
|
||||
const {
|
||||
data: subscriptionPreview,
|
||||
isPending: subscriptionPreviewIsLoading,
|
||||
isFetching: subscriptionPreviewIsFetching,
|
||||
isSuccess: subscriptionPreviewInitialized,
|
||||
} = subscriptionPreviewQueryResult
|
||||
|
||||
const { data: subscription } = useOrgSubscriptionQuery({
|
||||
orgSlug: slug,
|
||||
})
|
||||
const billingViaPartner = subscription?.billing_via_partner === true
|
||||
const billingPartner = subscription?.billing_partner
|
||||
|
||||
@@ -225,7 +228,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
// Features that will be lost when downgrading
|
||||
const featuresToLose =
|
||||
changeType === 'downgrade'
|
||||
? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => {
|
||||
? currentPlanFeatures.filter((feature) => {
|
||||
const featureStr = typeof feature === 'string' ? feature : feature[0]
|
||||
// Check if this feature exists in the new plan
|
||||
return !topFeatures.some((newFeature: string | string[]) => {
|
||||
@@ -367,6 +370,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{/* @ts-ignore [Joshen] Might be API types issue */}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
@@ -448,202 +452,9 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs mt-4">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Monthly invoice estimate</span>
|
||||
<InfoTooltip side="right">
|
||||
<div className="w-[520px] p-6">
|
||||
<h3 className="font-medium mb-2">Your new monthly invoice</h3>
|
||||
<p className="prose text-xs mb-2">
|
||||
First project included. Additional projects cost{' '}
|
||||
<span translate="no">$10</span>+/month regardless of activity.{' '}
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`}
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{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 &&
|
||||
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 = (
|
||||
<>
|
||||
{planItem && (
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="py-2! px-0">
|
||||
{planItem.description}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right py-2 px-0"
|
||||
translate="no"
|
||||
>
|
||||
{formatCurrency(planItem.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* Combined projects section */}
|
||||
{allProjects.length > 0 && (
|
||||
<>
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="py-2! px-0 flex items-center gap-1">
|
||||
<span>Compute</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right py-2 px-0"
|
||||
translate="no"
|
||||
>
|
||||
{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"
|
||||
translate="no"
|
||||
>
|
||||
{project.project_name} ({project.computeType}) |{' '}
|
||||
{formatCurrency(project.computeCosts)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{computeCreditsItem && (
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell
|
||||
className="py-2! px-0 pl-6"
|
||||
translate="no"
|
||||
>
|
||||
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">
|
||||
<span>{item.description ?? 'Unknown'}</span>
|
||||
{item.breakdown && 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"
|
||||
translate="no"
|
||||
>
|
||||
{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"
|
||||
translate="no"
|
||||
>
|
||||
{formatCurrency(
|
||||
subscriptionPreview?.breakdown?.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InfoTooltip>
|
||||
<InvoiceEstimateTooltip
|
||||
subscriptionPreviewQueryResult={subscriptionPreviewQueryResult}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-2 pr-0 text-right tabular-nums" translate="no">
|
||||
{formatCurrency(
|
||||
@@ -730,7 +541,7 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
Please review carefully before downgrading.
|
||||
</p>
|
||||
<div className="space-y-2 mb-4 text-foreground-light">
|
||||
{featuresToLose.map((feature: string | [string, ...any[]]) => (
|
||||
{featuresToLose.map((feature) => (
|
||||
<div
|
||||
key={typeof feature === 'string' ? feature : feature[0]}
|
||||
className="flex items-center gap-2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useQuery, UseQueryResult } from '@tanstack/react-query'
|
||||
|
||||
import { organizationKeys } from './keys'
|
||||
import type { CustomerAddress, CustomerTaxId } from './types'
|
||||
@@ -46,6 +46,11 @@ export type OrganizationBillingSubscriptionPreviewData = Awaited<
|
||||
ReturnType<typeof previewOrganizationBillingSubscription>
|
||||
>
|
||||
|
||||
export type OrganizationBillingSubscriptionPreviewQueryResult = UseQueryResult<
|
||||
OrganizationBillingSubscriptionPreviewData,
|
||||
ResponseError
|
||||
>
|
||||
|
||||
export const useOrganizationBillingSubscriptionPreview = <
|
||||
TData = OrganizationBillingSubscriptionPreviewData,
|
||||
>(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type PlanId = 'free' | 'pro' | 'team' | 'enterprise'
|
||||
|
||||
export interface PricingInformation {
|
||||
id: string
|
||||
id: 'tier_free' | 'tier_pro' | 'tier_team' | 'tier_enterprise'
|
||||
planId: PlanId
|
||||
name: string
|
||||
nameBadge?: string
|
||||
|
||||
Reference in new issue
Block a user