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