BillingMetric.tsx251 lines · main
1import { ChevronRight } from 'lucide-react'
2import Link from 'next/link'
3import { useMemo } from 'react'
4import { cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
5
6import { billingMetricUnit, formatUsage } from '../helpers'
7import { Metric, USAGE_APPROACHING_THRESHOLD } from './BillingBreakdown.constants'
8import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
9import { PricingMetric } from '@/data/analytics/org-daily-stats-query'
10import type { OrgSubscription } from '@/data/subscriptions/types'
11import type { OrgUsageResponse } from '@/data/usage/org-usage-query'
12import { formatCurrency } from '@/lib/helpers'
13
14export 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
24export 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>&nbsp;
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>&nbsp;
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}