BillingMetric.tsx 9.5 KB

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