RequestUpgradeToBillingOwners.tsx251 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PropsWithChildren, useState } from 'react'
3import { SubmitHandler, useForm } from 'react-hook-form'
4import { toast } from 'sonner'
5import {
6 Badge,
7 Button,
8 Dialog,
9 DialogContent,
10 DialogDescription,
11 DialogFooter,
12 DialogHeader,
13 DialogSection,
14 DialogSectionSeparator,
15 DialogTitle,
16 DialogTrigger,
17 Form,
18 FormControl,
19 FormField,
20 TextArea,
21 Tooltip,
22 TooltipContent,
23 TooltipTrigger,
24} from 'ui'
25import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
26import z from 'zod'
27
28import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
29import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
30import {
31 PlanRequest,
32 useSendUpgradeRequestMutation,
33} from '@/data/organizations/request-upgrade-mutation'
34import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
35import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
36import { useTrack } from '@/lib/telemetry/track'
37
38const FormSchema = z.object({
39 note: z.string().optional(),
40})
41
42const formId = 'request-upgrade-form'
43
44interface RequestUpgradeToBillingOwnersProps {
45 block?: boolean
46 plan?: PlanRequest
47 addon?: 'pitr' | 'customDomain' | 'ipv4' | 'spendCap' | 'computeSize'
48 /** Used in the default message template, e.g: "Upgrade to ..." */
49 featureProposition?: string
50 className?: string
51 type?: 'primary' | 'default'
52}
53
54export const RequestUpgradeToBillingOwners = ({
55 block = false,
56 plan = 'Pro',
57 addon,
58 featureProposition,
59 children,
60 className,
61 type = 'primary',
62}: PropsWithChildren<RequestUpgradeToBillingOwnersProps>) => {
63 const [open, setOpen] = useState(false)
64 const track = useTrack()
65 const { data: project } = useSelectedProjectQuery()
66 const { data: organization } = useSelectedOrganizationQuery()
67 const slug = organization?.slug
68 const currentPlan = organization?.plan?.id
69 const isFreePlan = currentPlan === 'free'
70
71 const { data: members = [] } = useOrganizationMembersQuery({ slug: organization?.slug })
72 const { data: roles } = useOrganizationRolesV2Query({ slug: organization?.slug })
73 const orgRoles = roles?.org_scoped_roles ?? []
74
75 const { mutate: sendUpgradeRequest, isPending: isSubmitting } = useSendUpgradeRequestMutation({
76 onSuccess: () => {
77 track('request_upgrade_submitted', {
78 requestedPlan: plan,
79 addon,
80 currentPlan,
81 })
82 toast.success('Successfully sent request to billing owners!')
83 setOpen(false)
84 },
85 })
86
87 const formattedAddonName =
88 addon === 'pitr'
89 ? 'PITR'
90 : addon === 'customDomain'
91 ? 'Custom domain'
92 : addon === 'ipv4'
93 ? 'dedicated IPv4 address'
94 : ''
95
96 const target = !!project
97 ? `for the project "${project?.name}"`
98 : !!organization
99 ? `for the organization "${organization.name}"`
100 : ''
101 const action =
102 addon === 'spendCap'
103 ? `disable spend cap`
104 : addon === 'computeSize'
105 ? `change the compute size`
106 : `enable the ${formattedAddonName} add-on`
107 const titleText = !!addon
108 ? addon === 'spendCap'
109 ? `Request to disable spend cap`
110 : addon === 'computeSize'
111 ? 'Request to change compute size'
112 : `Request to enable the ${formattedAddonName} add-on`
113 : `Request an upgrade for the ${plan} Plan`
114 const buttonText = !!children
115 ? children
116 : !!addon
117 ? addon === 'spendCap'
118 ? 'Request to disable spend cap'
119 : addon === 'computeSize'
120 ? 'Request to change compute'
121 : 'Request to enable addon'
122 : `Request upgrade to ${plan}`
123
124 const defaultValues = {
125 note: !!addon
126 ? `We'd like to ${isFreePlan ? 'upgrade to Pro and ' : ''}${action} ${target} so that we can ${featureProposition}`
127 : `We'd like to upgrade to the ${plan} plan ${!!featureProposition ? `to ${featureProposition} ` : ''}${target}`,
128 }
129 const form = useForm<z.infer<typeof FormSchema>>({
130 resolver: zodResolver(FormSchema as any),
131 defaultValues,
132 values: defaultValues,
133 })
134
135 // [Joshen] This is a pretty naive way of checking billing owners by raw role names
136 // Ideally we derive billing owners using permissions checking - but the current permissions
137 // logic is only contextualized to that of the current user, not other members
138 const billingOwners = members.filter((member) => {
139 const roles = member.role_ids
140 .map((x) => orgRoles.find((role) => role.id === x)?.name)
141 .filter(Boolean)
142 return !member.invited_id && (roles.includes('Owner') || roles.includes('Administrator'))
143 })
144
145 const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
146 if (!slug) return console.error('Slug is required')
147 sendUpgradeRequest({ slug, plan, note: values.note })
148 }
149
150 const handleOpenChange = (isOpen: boolean) => {
151 if (isOpen) {
152 track('request_upgrade_modal_opened', {
153 requestedPlan: plan,
154 addon,
155 currentPlan,
156 featureProposition,
157 })
158 }
159 setOpen(isOpen)
160 }
161
162 return (
163 <Dialog open={open} onOpenChange={handleOpenChange}>
164 <DialogTrigger asChild>
165 <Button block={block} type={type} className={className}>
166 {buttonText}
167 </Button>
168 </DialogTrigger>
169 <DialogContent>
170 <Form {...form}>
171 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
172 <DialogHeader>
173 <DialogTitle>{titleText}</DialogTitle>
174 <DialogDescription>
175 Let your organization's billing owners know your interest in this
176 </DialogDescription>
177 </DialogHeader>
178
179 <DialogSectionSeparator />
180
181 <DialogSection className="flex flex-col gap-y-6">
182 <div className="flex flex-col gap-y-2">
183 <p className="text-sm">
184 Your request will be sent to the following emails, who are billing owners of your
185 organization:
186 </p>
187 <div className="text-sm flex gap-x-2">
188 <p>
189 {billingOwners
190 .slice(0, 2)
191 .map((x) => x.primary_email)
192 .join(', ')}
193 </p>
194 {billingOwners.length > 2 && (
195 <Tooltip>
196 <TooltipTrigger tabIndex={-1}>
197 <Badge>+1 others</Badge>
198 </TooltipTrigger>
199 <TooltipContent side="bottom">
200 <ul className="">
201 {billingOwners.slice(2).map((x) => (
202 <li key={x.gotrue_id}>{x.primary_email}</li>
203 ))}
204 </ul>
205 </TooltipContent>
206 </Tooltip>
207 )}
208 </div>
209 </div>
210 <FormField
211 control={form.control}
212 name="note"
213 render={({ field }) => (
214 <FormItemLayout
215 name="note"
216 label="Add a note to your request (optional)"
217 layout="vertical"
218 >
219 <FormControl>
220 <TextArea
221 id="note"
222 {...field}
223 rows={3}
224 placeholder={
225 !!addon
226 ? addon === 'spendCap'
227 ? 'e.g. We need to disabled spend cap on this project to do something'
228 : 'e.g. We need to enable this add-on to do something with the project'
229 : 'e.g. We need to upgrade to the Pro plan to use this feature'
230 }
231 />
232 </FormControl>
233 </FormItemLayout>
234 )}
235 />
236 </DialogSection>
237
238 <DialogFooter>
239 <Button type="default" disabled={isSubmitting} onClick={() => setOpen(false)}>
240 Cancel
241 </Button>
242 <Button htmlType="submit" form={formId} loading={isSubmitting}>
243 Submit request
244 </Button>
245 </DialogFooter>
246 </form>
247 </Form>
248 </DialogContent>
249 </Dialog>
250 )
251}