InvoiceEstimateTooltip.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { HelpCircle } from 'lucide-react'
  2. import Link from 'next/link'
  3. import {
  4. cn,
  5. HoverCard,
  6. HoverCardContent,
  7. HoverCardTrigger,
  8. Table,
  9. TableBody,
  10. TableCell,
  11. TableRow,
  12. } from 'ui'
  13. import { InfoTooltip } from 'ui-patterns/info-tooltip'
  14. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  15. import AlertError from '@/components/ui/AlertError'
  16. import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview'
  17. import { DOCS_URL } from '@/lib/constants'
  18. import { formatCurrency } from '@/lib/helpers'
  19. const CELL_CLASSNAME = 'py-2 px-0'
  20. interface InvoiceEstimateTooltipProps {
  21. subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult
  22. }
  23. export const InvoiceEstimateTooltip = ({
  24. subscriptionPreviewQueryResult,
  25. }: InvoiceEstimateTooltipProps) => {
  26. const {
  27. data: subscriptionPreview,
  28. error: subscriptionPreviewError,
  29. isPending: subscriptionPreviewIsLoading,
  30. isSuccess: subscriptionPreviewInitialized,
  31. } = subscriptionPreviewQueryResult
  32. return (
  33. <HoverCard openDelay={50} closeDelay={50}>
  34. <HoverCardTrigger>
  35. <HelpCircle size={12} />
  36. </HoverCardTrigger>
  37. <HoverCardContent side="right" align="start" className="w-[400px] -translate-y-6">
  38. <h4 className="font-medium">Your new monthly invoice</h4>
  39. <p className="prose text-xs mb-2 text-balance">
  40. First project included. Additional projects cost <span translate="no">$10</span>+/month
  41. regardless of activity.{' '}
  42. <Link
  43. target="_blank"
  44. rel="noopener noreferrer"
  45. href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`}
  46. >
  47. Learn more
  48. </Link>
  49. .
  50. </p>
  51. {subscriptionPreviewError && (
  52. <AlertError error={subscriptionPreviewError} subject="Failed to preview subscription." />
  53. )}
  54. {subscriptionPreviewIsLoading && (
  55. <div className="space-y-2">
  56. <span className="text-sm">Estimating monthly costs...</span>
  57. <GenericSkeletonLoader />
  58. </div>
  59. )}
  60. {subscriptionPreviewInitialized && (
  61. <div className="max-h-[400px] overflow-y-auto">
  62. <Table className="[&_tr:last-child]:border-t font-mono text-xs">
  63. <TableBody>
  64. {/* Non-compute items and Projects list */}
  65. {(() => {
  66. // Combine all compute-related projects
  67. const computeItems =
  68. subscriptionPreview?.breakdown?.filter(
  69. (item) =>
  70. item.description?.toLowerCase().includes('compute') &&
  71. item.breakdown &&
  72. item.breakdown.length > 0
  73. ) || []
  74. const computeCreditsItem =
  75. subscriptionPreview?.breakdown?.find((item) =>
  76. item.description?.startsWith('Compute Credits')
  77. ) ?? null
  78. const planItem = subscriptionPreview?.breakdown?.find((item) =>
  79. item.description?.toLowerCase().includes('plan')
  80. )
  81. const allProjects = computeItems.flatMap((item) =>
  82. (item.breakdown || []).map((project) => ({
  83. ...project,
  84. computeType: item.description,
  85. computeCosts: Math.round(item.total_price / item.breakdown!.length),
  86. }))
  87. )
  88. const otherItems =
  89. subscriptionPreview?.breakdown?.filter(
  90. (item) =>
  91. !item.description?.toLowerCase().includes('compute') &&
  92. !item.description?.toLowerCase().includes('plan')
  93. ) || []
  94. const content = (
  95. <>
  96. {planItem && (
  97. <TableRow className="text-foreground-light">
  98. <TableCell className={CELL_CLASSNAME}>{planItem.description}</TableCell>
  99. <TableCell
  100. className={cn(CELL_CLASSNAME, 'text-foreground text-right')}
  101. translate="no"
  102. >
  103. {formatCurrency(planItem.total_price)}
  104. </TableCell>
  105. </TableRow>
  106. )}
  107. {/* Combined projects section */}
  108. {allProjects.length > 0 && (
  109. <>
  110. <TableRow className="text-foreground-light">
  111. <TableCell className={cn(CELL_CLASSNAME)}>
  112. <span>Compute</span>
  113. </TableCell>
  114. <TableCell
  115. translate="no"
  116. className={cn(CELL_CLASSNAME, 'text-foreground text-right')}
  117. >
  118. {formatCurrency(
  119. computeItems.reduce(
  120. (sum: number, item) => sum + item.total_price,
  121. 0
  122. ) + (computeCreditsItem?.total_price ?? 0)
  123. )}
  124. </TableCell>
  125. </TableRow>
  126. {allProjects.map((project) => (
  127. <TableRow key={project.project_ref} className="text-foreground-lighter">
  128. <TableCell translate="no" className={cn(CELL_CLASSNAME, 'pl-6')}>
  129. <p
  130. title={`${project.project_name} (${project.computeType})`}
  131. className="truncate max-w-64"
  132. >
  133. {project.project_name} ({project.computeType})
  134. </p>
  135. </TableCell>
  136. <TableCell
  137. translate="no"
  138. className={cn(CELL_CLASSNAME, 'text-right')}
  139. >
  140. {formatCurrency(project.computeCosts)}
  141. </TableCell>
  142. </TableRow>
  143. ))}
  144. {computeCreditsItem && (
  145. <TableRow className="text-foreground-lighter">
  146. <TableCell translate="no" className={cn(CELL_CLASSNAME, 'pl-6')}>
  147. Compute Credits
  148. </TableCell>
  149. <TableCell
  150. translate="no"
  151. className={cn(CELL_CLASSNAME, 'text-right')}
  152. >
  153. {formatCurrency(computeCreditsItem.total_price)}
  154. </TableCell>
  155. </TableRow>
  156. )}
  157. </>
  158. )}
  159. {/* Non-compute items */}
  160. {otherItems.map((item) => (
  161. <TableRow key={item.description} className="text-foreground-light">
  162. <TableCell className={cn(CELL_CLASSNAME, 'text-xs')}>
  163. <div className="flex items-center gap-1">
  164. <span>{item.description ?? 'Unknown'}</span>
  165. {item.breakdown && item.breakdown.length > 0 && (
  166. <InfoTooltip className="max-w-sm">
  167. <p>Projects using {item.description}:</p>
  168. <ul className="ml-6 list-disc">
  169. {item.breakdown.map((breakdown) => (
  170. <li
  171. key={`${item.description}-breakdown-${breakdown.project_ref}`}
  172. >
  173. {breakdown.project_name}
  174. </li>
  175. ))}
  176. </ul>
  177. </InfoTooltip>
  178. )}
  179. </div>
  180. </TableCell>
  181. <TableCell
  182. translate="no"
  183. className={cn(CELL_CLASSNAME, 'text-foreground text-right text-xs')}
  184. >
  185. {formatCurrency(item.total_price)}
  186. </TableCell>
  187. </TableRow>
  188. ))}
  189. </>
  190. )
  191. return content
  192. })()}
  193. <TableRow>
  194. <TableCell className="font-medium py-2 px-0">
  195. Total per month (excluding other usage)
  196. </TableCell>
  197. <TableCell className="text-right font-medium py-2 px-0" translate="no">
  198. {formatCurrency(
  199. subscriptionPreview?.breakdown?.reduce(
  200. (prev, cur) => prev + cur.total_price,
  201. 0
  202. ) ?? 0
  203. )}
  204. </TableCell>
  205. </TableRow>
  206. </TableBody>
  207. </Table>
  208. </div>
  209. )}
  210. </HoverCardContent>
  211. </HoverCard>
  212. )
  213. }