BillingMetric.tsx251 lines · main
| 1 | import { ChevronRight } from 'lucide-react' |
| 2 | import Link from 'next/link' |
| 3 | import { useMemo } from 'react' |
| 4 | import { cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' |
| 5 | |
| 6 | import { billingMetricUnit, formatUsage } from '../helpers' |
| 7 | import { Metric, USAGE_APPROACHING_THRESHOLD } from './BillingBreakdown.constants' |
| 8 | import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' |
| 9 | import { PricingMetric } from '@/data/analytics/org-daily-stats-query' |
| 10 | import type { OrgSubscription } from '@/data/subscriptions/types' |
| 11 | import type { OrgUsageResponse } from '@/data/usage/org-usage-query' |
| 12 | import { formatCurrency } from '@/lib/helpers' |
| 13 | |
| 14 | export interface BillingMetricProps { |
| 15 | idx: number |
| 16 | slug?: string |
| 17 | metric: Metric |
| 18 | usage: OrgUsageResponse |
| 19 | subscription: OrgSubscription |
| 20 | relativeToSubscription: boolean |
| 21 | className?: string |
| 22 | } |
| 23 | |
| 24 | export const BillingMetric = ({ |
| 25 | slug, |
| 26 | metric, |
| 27 | usage, |
| 28 | subscription, |
| 29 | relativeToSubscription, |
| 30 | className, |
| 31 | }: BillingMetricProps) => { |
| 32 | const usageMeta = usage.usages.find((x) => x.metric === metric.key) |
| 33 | |
| 34 | const usageLabel = useMemo(() => { |
| 35 | if (!usageMeta) return '' |
| 36 | |
| 37 | if (relativeToSubscription && usageMeta.available_in_plan === false) { |
| 38 | return 'Unavailable in plan' |
| 39 | } else if ( |
| 40 | (usageMeta.cost && usageMeta.cost > 0) || |
| 41 | !relativeToSubscription || |
| 42 | usageMeta.unlimited || |
| 43 | usageMeta.pricing_free_units === 0 |
| 44 | ) { |
| 45 | return metric.units === 'bytes' || metric.units === 'gigabytes' |
| 46 | ? `${usageMeta.usage.toLocaleString() ?? 0} GB` |
| 47 | : usageMeta.usage.toLocaleString() + (metric.unitName ? ` ${metric.unitName}` : '') |
| 48 | } else { |
| 49 | return metric.units === 'bytes' || metric.units === 'gigabytes' |
| 50 | ? `${usageMeta.usage.toLocaleString() ?? 0} / ${usageMeta.pricing_free_units ?? 0} GB` |
| 51 | : `${usageMeta.usage.toLocaleString()} / ${usageMeta.pricing_free_units?.toLocaleString()}` + |
| 52 | (metric.unitName ? ` ${metric.unitName}` : '') |
| 53 | } |
| 54 | }, [usageMeta, relativeToSubscription, metric]) |
| 55 | |
| 56 | const sortedProjectAllocations = useMemo(() => { |
| 57 | if (!usageMeta || !usageMeta.project_allocations) return [] |
| 58 | |
| 59 | return usageMeta.project_allocations.sort((a, b) => b.usage - a.usage) |
| 60 | }, [usageMeta]) |
| 61 | |
| 62 | if (!usageMeta) return null |
| 63 | |
| 64 | const usageRatio = |
| 65 | usageMeta.usage === 0 ? 0 : usageMeta.usage / (usageMeta.pricing_free_units ?? 0) |
| 66 | |
| 67 | const isUsageBillingEnabled = subscription?.usage_billing_enabled === true |
| 68 | |
| 69 | const hasLimit = !!usageMeta.unlimited === false |
| 70 | const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD |
| 71 | const isExceededLimit = relativeToSubscription && hasLimit && usageRatio >= 1 |
| 72 | |
| 73 | const unit = billingMetricUnit(usageMeta.metric as PricingMetric) |
| 74 | |
| 75 | const percentageLabel = |
| 76 | usageMeta.usage === 0 || usageMeta.pricing_free_units === 0 |
| 77 | ? '' |
| 78 | : usageRatio < 0.01 |
| 79 | ? '(<1%)' |
| 80 | : `(${(+(usageRatio * 100).toFixed(0)).toLocaleString()}%)` |
| 81 | |
| 82 | return ( |
| 83 | <HoverCard openDelay={50} closeDelay={200}> |
| 84 | <HoverCardTrigger asChild> |
| 85 | <div className={cn('flex items-center justify-between', className)}> |
| 86 | {metric.anchor ? ( |
| 87 | <Link href={`/org/${slug}/usage#${metric.anchor}`} className="block w-full group"> |
| 88 | <div className="group flex items-center gap-1"> |
| 89 | <p className="text-sm text-foreground-light group-hover:text-foreground transition cursor-pointer"> |
| 90 | {metric.name} |
| 91 | </p> |
| 92 | {usageMeta.available_in_plan && ( |
| 93 | <span className="text-foreground-muted transition inline-block group-hover:transform group-hover:translate-x-0.5"> |
| 94 | <ChevronRight strokeWidth={1.5} size={16} className="transition" /> |
| 95 | </span> |
| 96 | )} |
| 97 | </div> |
| 98 | <span className="text-sm">{usageLabel}</span> |
| 99 | {relativeToSubscription && usageMeta.cost && usageMeta.cost > 0 ? ( |
| 100 | <span className="text-sm" translate="no"> |
| 101 | ({formatCurrency(usageMeta.cost)}) |
| 102 | </span> |
| 103 | ) : usageMeta.available_in_plan && |
| 104 | usageMeta.pricing_free_units !== 0 && |
| 105 | !usageMeta.unlimited && |
| 106 | relativeToSubscription ? ( |
| 107 | <span className="text-sm">{percentageLabel}</span> |
| 108 | ) : null} |
| 109 | </Link> |
| 110 | ) : ( |
| 111 | <div className="block w-full"> |
| 112 | <p className="text-sm text-foreground-light flex space-x-1">{metric.name}</p> |
| 113 | <span className="text-sm">{usageLabel}</span> |
| 114 | {relativeToSubscription && usageMeta.cost && usageMeta.cost > 0 ? ( |
| 115 | <span className="text-sm" translate="no"> |
| 116 | ({formatCurrency(usageMeta.cost)}) |
| 117 | </span> |
| 118 | ) : usageMeta.available_in_plan && |
| 119 | usageMeta.pricing_free_units !== 0 && |
| 120 | !usageMeta.unlimited && |
| 121 | relativeToSubscription ? ( |
| 122 | <span className="text-sm">{percentageLabel}</span> |
| 123 | ) : null} |
| 124 | </div> |
| 125 | )} |
| 126 | |
| 127 | {usageMeta.available_in_plan ? ( |
| 128 | <div> |
| 129 | {relativeToSubscription && |
| 130 | !usageMeta.unlimited && |
| 131 | usageMeta.pricing_free_units !== 0 ? ( |
| 132 | <svg className="h-8 w-8 -rotate-90 transform"> |
| 133 | <circle |
| 134 | cx={15} |
| 135 | cy={15} |
| 136 | r={12} |
| 137 | fill="transparent" |
| 138 | stroke="currentColor" |
| 139 | strokeWidth={4} |
| 140 | className="text-background-surface-300" |
| 141 | /> |
| 142 | <circle |
| 143 | cx={15} |
| 144 | cy={15} |
| 145 | r={12} |
| 146 | fill="transparent" |
| 147 | stroke="currentColor" |
| 148 | strokeDasharray={75.398} |
| 149 | strokeDashoffset={`calc(75.39822 - ${ |
| 150 | usageRatio < 1 ? usageRatio * 100 : 100 |
| 151 | } / 100 * 75.39822)`} |
| 152 | strokeWidth={4} |
| 153 | className={ |
| 154 | isUsageBillingEnabled |
| 155 | ? 'text-gray-dark-800' |
| 156 | : isExceededLimit |
| 157 | ? 'text-red-900' |
| 158 | : isApproachingLimit |
| 159 | ? 'text-yellow-1000' |
| 160 | : 'text-gray-dark-800' |
| 161 | } |
| 162 | /> |
| 163 | </svg> |
| 164 | ) : null} |
| 165 | </div> |
| 166 | ) : ( |
| 167 | <div> |
| 168 | <UpgradePlanButton |
| 169 | source={`billingBreakdownUsage${metric.anchor}`} |
| 170 | featureProposition={`to use ${metric.name}`} |
| 171 | > |
| 172 | Upgrade |
| 173 | </UpgradePlanButton> |
| 174 | </div> |
| 175 | )} |
| 176 | </div> |
| 177 | </HoverCardTrigger> |
| 178 | {usageMeta.available_in_plan && ( |
| 179 | <HoverCardContent side="bottom" align="end" className="w-[500px]" animate="slide-in"> |
| 180 | <div className="text-sm"> |
| 181 | <p className="font-medium" translate="no"> |
| 182 | {usageMeta.unit_price_desc} |
| 183 | </p> |
| 184 | |
| 185 | {metric.tip && ( |
| 186 | <div className="my-2"> |
| 187 | <p className="text-sm"> |
| 188 | {metric.tip}{' '} |
| 189 | {metric.docLink && ( |
| 190 | <Link |
| 191 | href={metric.docLink.url} |
| 192 | target="_blank" |
| 193 | className="transition text-brand hover:text-brand-600 underline" |
| 194 | > |
| 195 | {metric.docLink.title} |
| 196 | </Link> |
| 197 | )} |
| 198 | </p> |
| 199 | </div> |
| 200 | )} |
| 201 | |
| 202 | {subscription.usage_billing_enabled === false && |
| 203 | relativeToSubscription && |
| 204 | (isApproachingLimit || isExceededLimit) && ( |
| 205 | <div className="my-2"> |
| 206 | <p className="text-sm"> |
| 207 | Exceeding your plans included usage will lead to restrictions to your project. |
| 208 | Upgrade to a usage-based plan or disable the spend cap to avoid restrictions. |
| 209 | </p> |
| 210 | </div> |
| 211 | )} |
| 212 | |
| 213 | {sortedProjectAllocations && sortedProjectAllocations.length > 0 && ( |
| 214 | <table className="list-disc w-full"> |
| 215 | <thead> |
| 216 | <tr> |
| 217 | <th className="text-left">Project</th> |
| 218 | <th className="text-right">Usage</th> |
| 219 | </tr> |
| 220 | </thead> |
| 221 | <tbody> |
| 222 | {sortedProjectAllocations.map((allocation) => ( |
| 223 | <tr key={`${usageMeta.metric}_${allocation.ref}`}> |
| 224 | <td>{allocation.name}</td> |
| 225 | <td className="text-right"> |
| 226 | {formatUsage(usageMeta.metric as PricingMetric, allocation)} |
| 227 | </td> |
| 228 | </tr> |
| 229 | ))} |
| 230 | <tr></tr> |
| 231 | </tbody> |
| 232 | <tfoot> |
| 233 | <tr> |
| 234 | <td className="py-2 border-t text-left"> |
| 235 | Total{unit && <span> ({unit})</span>} |
| 236 | </td> |
| 237 | <td className="py-2 border-t text-right"> |
| 238 | {formatUsage(usageMeta.metric as PricingMetric, { |
| 239 | usage: usageMeta.usage_original, |
| 240 | })}{' '} |
| 241 | </td> |
| 242 | </tr> |
| 243 | </tfoot> |
| 244 | </table> |
| 245 | )} |
| 246 | </div> |
| 247 | </HoverCardContent> |
| 248 | )} |
| 249 | </HoverCard> |
| 250 | ) |
| 251 | } |