RequestUpgradeToBillingOwners.tsx 8.3 KB

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