| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PropsWithChildren, useState } from 'react'
- import { SubmitHandler, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Badge,
- Button,
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogSection,
- DialogSectionSeparator,
- DialogTitle,
- DialogTrigger,
- Form,
- FormControl,
- FormField,
- TextArea,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import z from 'zod'
- import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
- import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
- import {
- PlanRequest,
- useSendUpgradeRequestMutation,
- } from '@/data/organizations/request-upgrade-mutation'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useTrack } from '@/lib/telemetry/track'
- const FormSchema = z.object({
- note: z.string().optional(),
- })
- const formId = 'request-upgrade-form'
- interface RequestUpgradeToBillingOwnersProps {
- block?: boolean
- plan?: PlanRequest
- addon?: 'pitr' | 'customDomain' | 'ipv4' | 'spendCap' | 'computeSize'
- /** Used in the default message template, e.g: "Upgrade to ..." */
- featureProposition?: string
- className?: string
- type?: 'primary' | 'default'
- }
- export const RequestUpgradeToBillingOwners = ({
- block = false,
- plan = 'Pro',
- addon,
- featureProposition,
- children,
- className,
- type = 'primary',
- }: PropsWithChildren<RequestUpgradeToBillingOwnersProps>) => {
- const [open, setOpen] = useState(false)
- const track = useTrack()
- const { data: project } = useSelectedProjectQuery()
- const { data: organization } = useSelectedOrganizationQuery()
- const slug = organization?.slug
- const currentPlan = organization?.plan?.id
- const isFreePlan = currentPlan === 'free'
- const { data: members = [] } = useOrganizationMembersQuery({ slug: organization?.slug })
- const { data: roles } = useOrganizationRolesV2Query({ slug: organization?.slug })
- const orgRoles = roles?.org_scoped_roles ?? []
- const { mutate: sendUpgradeRequest, isPending: isSubmitting } = useSendUpgradeRequestMutation({
- onSuccess: () => {
- track('request_upgrade_submitted', {
- requestedPlan: plan,
- addon,
- currentPlan,
- })
- toast.success('Successfully sent request to billing owners!')
- setOpen(false)
- },
- })
- const formattedAddonName =
- addon === 'pitr'
- ? 'PITR'
- : addon === 'customDomain'
- ? 'Custom domain'
- : addon === 'ipv4'
- ? 'dedicated IPv4 address'
- : ''
- const target = !!project
- ? `for the project "${project?.name}"`
- : !!organization
- ? `for the organization "${organization.name}"`
- : ''
- const action =
- addon === 'spendCap'
- ? `disable spend cap`
- : addon === 'computeSize'
- ? `change the compute size`
- : `enable the ${formattedAddonName} add-on`
- const titleText = !!addon
- ? addon === 'spendCap'
- ? `Request to disable spend cap`
- : addon === 'computeSize'
- ? 'Request to change compute size'
- : `Request to enable the ${formattedAddonName} add-on`
- : `Request an upgrade for the ${plan} Plan`
- const buttonText = !!children
- ? children
- : !!addon
- ? addon === 'spendCap'
- ? 'Request to disable spend cap'
- : addon === 'computeSize'
- ? 'Request to change compute'
- : 'Request to enable addon'
- : `Request upgrade to ${plan}`
- const defaultValues = {
- note: !!addon
- ? `We'd like to ${isFreePlan ? 'upgrade to Pro and ' : ''}${action} ${target} so that we can ${featureProposition}`
- : `We'd like to upgrade to the ${plan} plan ${!!featureProposition ? `to ${featureProposition} ` : ''}${target}`,
- }
- const form = useForm<z.infer<typeof FormSchema>>({
- resolver: zodResolver(FormSchema as any),
- defaultValues,
- values: defaultValues,
- })
- // [Joshen] This is a pretty naive way of checking billing owners by raw role names
- // Ideally we derive billing owners using permissions checking - but the current permissions
- // logic is only contextualized to that of the current user, not other members
- const billingOwners = members.filter((member) => {
- const roles = member.role_ids
- .map((x) => orgRoles.find((role) => role.id === x)?.name)
- .filter(Boolean)
- return !member.invited_id && (roles.includes('Owner') || roles.includes('Administrator'))
- })
- const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
- if (!slug) return console.error('Slug is required')
- sendUpgradeRequest({ slug, plan, note: values.note })
- }
- const handleOpenChange = (isOpen: boolean) => {
- if (isOpen) {
- track('request_upgrade_modal_opened', {
- requestedPlan: plan,
- addon,
- currentPlan,
- featureProposition,
- })
- }
- setOpen(isOpen)
- }
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogTrigger asChild>
- <Button block={block} type={type} className={className}>
- {buttonText}
- </Button>
- </DialogTrigger>
- <DialogContent>
- <Form {...form}>
- <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
- <DialogHeader>
- <DialogTitle>{titleText}</DialogTitle>
- <DialogDescription>
- Let your organization's billing owners know your interest in this
- </DialogDescription>
- </DialogHeader>
- <DialogSectionSeparator />
- <DialogSection className="flex flex-col gap-y-6">
- <div className="flex flex-col gap-y-2">
- <p className="text-sm">
- Your request will be sent to the following emails, who are billing owners of your
- organization:
- </p>
- <div className="text-sm flex gap-x-2">
- <p>
- {billingOwners
- .slice(0, 2)
- .map((x) => x.primary_email)
- .join(', ')}
- </p>
- {billingOwners.length > 2 && (
- <Tooltip>
- <TooltipTrigger tabIndex={-1}>
- <Badge>+1 others</Badge>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- <ul className="">
- {billingOwners.slice(2).map((x) => (
- <li key={x.gotrue_id}>{x.primary_email}</li>
- ))}
- </ul>
- </TooltipContent>
- </Tooltip>
- )}
- </div>
- </div>
- <FormField
- control={form.control}
- name="note"
- render={({ field }) => (
- <FormItemLayout
- name="note"
- label="Add a note to your request (optional)"
- layout="vertical"
- >
- <FormControl>
- <TextArea
- id="note"
- {...field}
- rows={3}
- placeholder={
- !!addon
- ? addon === 'spendCap'
- ? 'e.g. We need to disabled spend cap on this project to do something'
- : 'e.g. We need to enable this add-on to do something with the project'
- : 'e.g. We need to upgrade to the Pro plan to use this feature'
- }
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </DialogSection>
- <DialogFooter>
- <Button type="default" disabled={isSubmitting} onClick={() => setOpen(false)}>
- Cancel
- </Button>
- <Button htmlType="submit" form={formId} loading={isSubmitting}>
- Submit request
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
- }
|