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