BillingEmail.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { Form, FormControl, FormField } from '@ui/components/shadcn/ui/form'
  4. import { useParams } from 'common'
  5. import { useEffect } from 'react'
  6. import { useForm } from 'react-hook-form'
  7. import { toast } from 'sonner'
  8. import { FormMessage, Input } from 'ui'
  9. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  10. import { InfoTooltip } from 'ui-patterns/info-tooltip'
  11. import {
  12. MultiSelector,
  13. MultiSelectorContent,
  14. MultiSelectorList,
  15. MultiSelectorTrigger,
  16. } from 'ui-patterns/multi-select'
  17. import { z } from 'zod'
  18. import {
  19. ScaffoldSection,
  20. ScaffoldSectionContent,
  21. ScaffoldSectionDetail,
  22. } from '@/components/layouts/Scaffold'
  23. import { FormActions } from '@/components/ui/Forms/FormActions'
  24. import { FormPanel } from '@/components/ui/Forms/FormPanel'
  25. import { FormSection, FormSectionContent } from '@/components/ui/Forms/FormSection'
  26. import NoPermission from '@/components/ui/NoPermission'
  27. import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
  28. import { useOrganizationUpdateMutation } from '@/data/organizations/organization-update-mutation'
  29. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  30. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  31. const FORM_ID = 'org-billing-email'
  32. const formSchema = z.object({
  33. billingEmail: z.string().email('Please provide a valid email address').optional(),
  34. additionalBillingEmails: z.string().email({ message: 'invalid_email' }).array().default([]),
  35. })
  36. const BillingEmail = () => {
  37. const { slug } = useParams()
  38. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  39. const { name, billing_email } = selectedOrganization ?? {}
  40. const { can: canReadBillingEmail, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
  41. PermissionAction.BILLING_READ,
  42. 'stripe.subscriptions'
  43. )
  44. const { can: canUpdateOrganization } = useAsyncCheckPermissions(
  45. PermissionAction.UPDATE,
  46. 'organizations'
  47. )
  48. const { data: billingCustomer, isPending: loadingBillingCustomer } =
  49. useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail })
  50. const form = useForm<z.infer<typeof formSchema>>({
  51. resolver: zodResolver(formSchema as any),
  52. defaultValues: {
  53. billingEmail: billing_email ?? '',
  54. additionalBillingEmails: billingCustomer?.additional_emails ?? [],
  55. },
  56. })
  57. const { additionalBillingEmails } = form.watch()
  58. const { errors } = form.formState
  59. const additionalEmailsError = errors.additionalBillingEmails ?? []
  60. const { mutate: updateOrganization, isPending: isUpdating } = useOrganizationUpdateMutation()
  61. const onUpdateOrganizationEmail = async (values: z.infer<typeof formSchema>) => {
  62. if (!canUpdateOrganization) {
  63. return toast.error('You do not have the required permissions to update this organization')
  64. }
  65. if (!slug) return console.error('Slug is required')
  66. if (!name) return console.error('Organization name is required')
  67. updateOrganization(
  68. {
  69. slug,
  70. name,
  71. billing_email: values.billingEmail,
  72. additional_billing_emails: values.additionalBillingEmails,
  73. },
  74. {
  75. onSuccess: () => {
  76. toast.success('Successfully saved settings')
  77. form.reset(values)
  78. },
  79. }
  80. )
  81. }
  82. useEffect(() => {
  83. if (billingCustomer) {
  84. form.reset({
  85. billingEmail: billing_email ?? '',
  86. additionalBillingEmails: billingCustomer.additional_emails ?? [],
  87. })
  88. }
  89. }, [billingCustomer])
  90. return (
  91. <ScaffoldSection>
  92. <ScaffoldSectionDetail>
  93. <div className="sticky space-y-2 top-12">
  94. <p className="text-foreground text-base m-0">Email Recipient</p>
  95. <p className="text-sm text-foreground-light m-0">
  96. All billing correspondence will go to this email
  97. </p>
  98. </div>
  99. </ScaffoldSectionDetail>
  100. <ScaffoldSectionContent>
  101. {isPermissionsLoaded && !canReadBillingEmail ? (
  102. <NoPermission resourceText="view this organization's email recipients" />
  103. ) : (
  104. <Form {...form}>
  105. <form id={FORM_ID} onSubmit={form.handleSubmit(onUpdateOrganizationEmail)}>
  106. <FormPanel
  107. footer={
  108. <div className="flex py-4 px-8">
  109. <FormActions
  110. form={FORM_ID}
  111. isSubmitting={isUpdating}
  112. hasChanges={form.formState.isDirty}
  113. handleReset={form.reset}
  114. disabled={!canUpdateOrganization}
  115. helper={
  116. !canUpdateOrganization
  117. ? 'You need additional permissions to update billing emails'
  118. : undefined
  119. }
  120. />
  121. </div>
  122. }
  123. >
  124. <FormSection className="px-8!">
  125. <FormSectionContent fullWidth loading={loadingBillingCustomer}>
  126. <FormField
  127. control={form.control}
  128. name="billingEmail"
  129. render={({ field }) => (
  130. <FormItemLayout label="Email address">
  131. <FormControl>
  132. <Input
  133. type="email"
  134. {...field}
  135. placeholder="Email"
  136. disabled={!canUpdateOrganization}
  137. />
  138. </FormControl>
  139. <FormMessage />
  140. </FormItemLayout>
  141. )}
  142. />
  143. <FormField
  144. control={form.control}
  145. name="additionalBillingEmails"
  146. render={({ field }) => (
  147. <FormItemLayout
  148. hideMessage
  149. label={
  150. <div className="flex items-center gap-x-1">
  151. <span>Additional emails</span>
  152. <InfoTooltip side="bottom">
  153. These email addresses will be CC'd in automated invoice or payment
  154. failure emails. Payment receipts will still only go to the primary
  155. billing address.
  156. </InfoTooltip>
  157. </div>
  158. }
  159. >
  160. <FormControl>
  161. <MultiSelector values={field.value} onValuesChange={field.onChange}>
  162. <MultiSelectorTrigger
  163. deletableBadge
  164. showIcon={false}
  165. mode="inline-combobox"
  166. label="Add additional recipients"
  167. badgeLimit="wrap"
  168. />
  169. <MultiSelectorContent>
  170. <MultiSelectorList creatable />
  171. </MultiSelectorContent>
  172. </MultiSelector>
  173. </FormControl>
  174. {/* [Joshen] Manually construct the message here as MultiSelector doesn't handle array errors from RHF atm */}
  175. {Array.isArray(additionalEmailsError) &&
  176. additionalEmailsError.length > 0 && (
  177. <div className="flex flex-col gap-y-1 mt-2">
  178. {additionalEmailsError.map((_x, idx) => (
  179. <p
  180. key={`email-error-${idx}`}
  181. className="text-sm text-destructive"
  182. >
  183. "{additionalBillingEmails[idx]}" is not a valid email address
  184. </p>
  185. ))}
  186. </div>
  187. )}
  188. </FormItemLayout>
  189. )}
  190. />
  191. </FormSectionContent>
  192. </FormSection>
  193. </FormPanel>
  194. </form>
  195. </Form>
  196. )}
  197. </ScaffoldSectionContent>
  198. </ScaffoldSection>
  199. )
  200. }
  201. export default BillingEmail