Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/features/dashboard/billing/addons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { useDashboard } from '../context'
import { ConcurrentSandboxAddOnPurchaseDialog } from './concurrent-sandboxes-addon-dialog'
import { ADDON_500_SANDBOXES_ID, TIER_PRO_ID } from './constants'
import { useBillingItems } from './hooks'
import { formatAddonQuantity } from './utils'
import { formatAddonQuantity, isEnterpriseTier } from './utils'

interface AddonItemProps {
name: string
Expand Down Expand Up @@ -222,6 +222,12 @@ export default function Addons() {
)
}

const isEnterprise = selectedTierId ? isEnterpriseTier(selectedTierId) : false

if (isEnterprise) {
return null
}

if (!isOnProTier) {
return (
<section className="flex flex-col gap-6">
Expand Down
15 changes: 13 additions & 2 deletions src/features/dashboard/billing/credits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { formatCurrency } from '@/lib/utils/formatting'
import { Label } from '@/ui/primitives/label'
import { Separator } from '@/ui/primitives/separator'
import { Skeleton } from '@/ui/primitives/skeleton'
import { useDashboard } from '../context'
import { useUsage } from './hooks'
import { isEnterpriseTier } from './utils'

export default function Credits() {
const { credits, isLoading } = useUsage()
const { team } = useDashboard()
const isEnterprise = isEnterpriseTier(team.tier)

return (
<section>
Expand All @@ -17,14 +21,21 @@ export default function Credits() {
<div className="w-full">
{isLoading ? (
<Skeleton className="h-5 w-16" />
) : (
) : isEnterprise ? null : (
<span className="prose-value-small">
{formatCurrency(credits ?? 0)}
</span>
)}
</div>
<p className="prose-body text-fg-tertiary whitespace-nowrap">
Automatically applied to invoices. Subscription costs excluded.
{isEnterprise ? (
<>
Automatically applied to invoices{' '}
<span className="text-fg">as per contract</span>
</>
) : (
'Automatically applied to invoices. Subscription costs excluded.'
)}
</p>
</div>
<Separator className="mt-3" />
Expand Down
17 changes: 14 additions & 3 deletions src/features/dashboard/billing/invoices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
TableLoadingState,
TableRow,
} from '@/ui/primitives/table'
import { useDashboard } from '../context'
import { useInvoices } from './hooks'
import { isEnterpriseTier } from './utils'

const COLUMN_WIDTHS = {
date: 120,
Expand All @@ -43,9 +45,14 @@ function formatDate(dateString: string) {

interface InvoicesEmptyProps {
error?: string
isEnterprise?: boolean
}

function InvoicesEmpty({ error }: InvoicesEmptyProps) {
function InvoicesEmpty({ error, isEnterprise }: InvoicesEmptyProps) {
const emptyMessage = isEnterprise
? 'Invoices are sent directly to your company via email.'
: 'No invoices yet'

return (
<TableEmptyState colSpan={4}>
<InvoiceIcon
Expand All @@ -55,14 +62,16 @@ function InvoicesEmpty({ error }: InvoicesEmptyProps) {
)}
/>
<span className={cn(error && 'text-accent-error-highlight')}>
{error ? error : 'No invoices yet'}
{error ? error : emptyMessage}
</span>
</TableEmptyState>
)
}

export default function BillingInvoicesTable() {
const { invoices, isLoading, error } = useInvoices()
const { team } = useDashboard()
const isEnterprise = isEnterpriseTier(team.tier)

const hasData = invoices && invoices.length > 0
const showLoader = isLoading && !hasData
Expand Down Expand Up @@ -99,7 +108,9 @@ export default function BillingInvoicesTable() {
{showLoader && (
<TableLoadingState colSpan={4} label="Loading invoices" />
)}
{showEmpty && <InvoicesEmpty error={error?.message} />}
{showEmpty && (
<InvoicesEmpty error={error?.message} isEnterprise={isEnterprise} />
)}

{hasData &&
invoices.map((invoice) => (
Expand Down
18 changes: 15 additions & 3 deletions src/features/dashboard/billing/select-plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { useDashboard } from '../context'
import { TIER_BASE_ID, TIER_PRO_ID } from './constants'
import { useBillingItems } from './hooks'
import { TierAvatarBorder } from './tier-avatar-border'
import { formatHours, formatMibToGb, formatTierDisplayName } from './utils'
import {
formatHours,
formatMibToGb,
formatTierDisplayName,
isEnterpriseTier,
} from './utils'

interface PlanFeature {
icon: React.ReactNode
Expand Down Expand Up @@ -121,6 +126,7 @@ interface PlanCardProps {
isLoading?: boolean
onSelectPlan: () => void
isSelectingPlan: boolean
disabled?: boolean
}

function PlanCardSkeleton() {
Expand Down Expand Up @@ -154,6 +160,7 @@ function PlanCard({
isLoading,
onSelectPlan,
isSelectingPlan,
disabled,
}: PlanCardProps) {
const { team } = useDashboard()

Expand Down Expand Up @@ -197,8 +204,10 @@ function PlanCard({
<span className="prose-value-big text-fg">{priceDisplay}</span>
</div>

{isCurrentPlan ? (
<Button disabled>Your current plan</Button>
{isCurrentPlan || disabled ? (
<Button disabled>
{isCurrentPlan ? 'Your current plan' : buttonText}
</Button>
) : (
<div className="flex items-center gap-4 flex-wrap">
<span className="prose-body text-fg-tertiary">
Expand Down Expand Up @@ -285,6 +294,7 @@ export default function SelectPlan() {

const isOnBaseTier = selectedTierId === TIER_BASE_ID
const isOnProTier = selectedTierId === TIER_PRO_ID
const isEnterprise = selectedTierId ? isEnterpriseTier(selectedTierId) : false

const hobbyFeatures = getHobbyFeatures(baseTier)
const proFeatures = getProFeatures(proTier)
Expand All @@ -299,6 +309,7 @@ export default function SelectPlan() {
isLoading={isLoading}
onSelectPlan={handleDowngrade}
isSelectingPlan={isPortalLoading}
disabled={isEnterprise}
/>
<PlanCard
tier={proTier}
Expand All @@ -308,6 +319,7 @@ export default function SelectPlan() {
isLoading={isLoading}
onSelectPlan={() => proTier?.id && handleUpgrade(proTier.id)}
isSelectingPlan={isCheckoutLoading}
disabled={isEnterprise}
/>
</section>
)
Expand Down
103 changes: 59 additions & 44 deletions src/features/dashboard/billing/selected-plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import { useDashboard } from '../context'
import { useBillingItems } from './hooks'
import { TierAvatarBorder } from './tier-avatar-border'
import type { BillingTierData } from './types'
import { formatHours, formatMibToGb, formatTierDisplayName } from './utils'
import {
formatHours,
formatMibToGb,
formatTierDisplayName,
isEnterpriseTier,
} from './utils'

function formatCpu(vcpu: number): string {
return `${vcpu} vCPU`
Expand Down Expand Up @@ -88,6 +93,7 @@ function PlanDetails({
isLoading,
}: PlanDetailsProps) {
const isBaseTier = !selectedTier || selectedTier.id.includes('base')
const isEnterprise = selectedTier ? isEnterpriseTier(selectedTier.id) : false
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use team tier for enterprise gating

When the billing items response does not include the current enterprise tier in tiers.available (extractTierData sets selected with available.find(t => t.id === tiers.current)), selectedTier is undefined even though useDashboard().team.tier can identify the team as enterprise. In that scenario this line sets isEnterprise to false, so the billing pages still render the price badge and plan-management buttons; the same selectedTierId pattern in Addons/SelectPlan leaves add-ons and plan-selection controls visible. Credits/invoices already use team.tier, so these enterprise guards need the same source or a fallback to tiers.current.

Useful? React with 👍 / 👎.

const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/billing'>()
const pathname = usePathname()
const router = useRouter()
Expand Down Expand Up @@ -116,44 +122,50 @@ function PlanDetails({
return (
<div className="flex flex-col pt-2 pr-2 pb-1 w-full">
<div className="flex items-start justify-between gap-4 max-lg:flex-col">
<PlanTitle selectedTier={selectedTier} isLoading={isLoading} />

<div className="flex items-center gap-1 flex-wrap">
{isOnPlanPage ? (
<Button variant="secondary" asChild>
<Link href={PROTECTED_URLS.BILLING_PLAN_SELECT(teamSlug)}>
Change Plan
</Link>
</Button>
) : isLoading ? (
<Skeleton className="h-8 w-36" />
) : (
<>
{isBaseTier ? (
<Button asChild>
<Link href={PROTECTED_URLS.BILLING_PLAN_SELECT(teamSlug)}>
<UpgradeIcon className="size-4" />
Upgrade for higher concurrency
</Link>
</Button>
) : (
<Button variant="secondary" asChild>
<Link href={PROTECTED_URLS.BILLING_PLAN(teamSlug)}>
Manage plan & add-ons
</Link>
</Button>
)}
<Button
variant="secondary"
onClick={handleManagePayment}
loading={isPortalLoading ? 'Loading...' : undefined}
disabled={isPortalLoading}
>
Manage payment
<PlanTitle
selectedTier={selectedTier}
isLoading={isLoading}
isEnterprise={isEnterprise}
/>

{!isEnterprise && (
<div className="flex items-center gap-1 flex-wrap">
{isOnPlanPage ? (
<Button variant="secondary" asChild>
<Link href={PROTECTED_URLS.BILLING_PLAN_SELECT(teamSlug)}>
Change Plan
</Link>
</Button>
</>
)}
</div>
) : isLoading ? (
<Skeleton className="h-8 w-36" />
) : (
<>
{isBaseTier ? (
<Button asChild>
<Link href={PROTECTED_URLS.BILLING_PLAN_SELECT(teamSlug)}>
<UpgradeIcon className="size-4" />
Upgrade for higher concurrency
</Link>
</Button>
) : (
<Button variant="secondary" asChild>
<Link href={PROTECTED_URLS.BILLING_PLAN(teamSlug)}>
Manage plan & add-ons
</Link>
</Button>
)}
<Button
variant="secondary"
onClick={handleManagePayment}
loading={isPortalLoading ? 'Loading...' : undefined}
disabled={isPortalLoading}
>
Manage payment
</Button>
</>
)}
</div>
)}
</div>

<Separator className="my-4" />
Expand All @@ -166,9 +178,10 @@ function PlanDetails({
interface PlanTitleProps {
selectedTier: BillingTierData['selected']
isLoading: boolean
isEnterprise?: boolean
}

function PlanTitle({ selectedTier, isLoading }: PlanTitleProps) {
function PlanTitle({ selectedTier, isLoading, isEnterprise }: PlanTitleProps) {
return (
<div className="flex flex-col gap-2">
<Label className="text-fg-tertiary prose-label">Plan</Label>
Expand All @@ -179,11 +192,13 @@ function PlanTitle({ selectedTier, isLoading }: PlanTitleProps) {
<span className="prose-value-big uppercase text-fg">
{selectedTier ? formatTierDisplayName(selectedTier.name) : null}
</span>
<Badge className="uppercase translate-y-1">
{selectedTier?.price_cents
? `${formatCurrency(selectedTier.price_cents / 100)}/mo`
: 'FREE'}
</Badge>
{!isEnterprise && (
<Badge className="uppercase translate-y-1">
{selectedTier?.price_cents
? `${formatCurrency(selectedTier.price_cents / 100)}/mo`
: 'FREE'}
</Badge>
)}
</div>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/features/dashboard/billing/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { l } from '@/core/shared/clients/logger/logger'
import { ADDON_500_SANDBOXES_ID, TIER_BASE_ID, TIER_PRO_ID } from './constants'
import type { BillingAddonData, BillingTierData } from './types'

export function isEnterpriseTier(tierIdOrName: string): boolean {
return tierIdOrName.toLowerCase().includes('enterprise')
}

export function formatTierDisplayName(name: string): string {
if (name.toLowerCase().includes('base')) return 'Hobby'
if (name.toLowerCase().includes('pro')) return 'Professional'
if (isEnterpriseTier(name)) return 'Enterprise'
return name
}

Expand Down
Loading