CustomDomainSidePanel.tsx233 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useFlag, useParams } from 'common'
3import { AlertCircle } from 'lucide-react'
4import { useEffect, useState } from 'react'
5import { toast } from 'sonner'
6import {
7 Alert,
8 AlertDescription,
9 AlertTitle,
10 cn,
11 RadioGroupCard,
12 RadioGroupCardItem,
13 SidePanel,
14} from 'ui'
15
16import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
17import { DocsButton } from '@/components/ui/DocsButton'
18import { InlineLink } from '@/components/ui/InlineLink'
19import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
20import { useProjectAddonRemoveMutation } from '@/data/subscriptions/project-addon-remove-mutation'
21import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation'
22import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
23import type { AddonVariantId } from '@/data/subscriptions/types'
24import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
25import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
26import { DOCS_URL } from '@/lib/constants'
27import { formatCurrency } from '@/lib/helpers'
28import { useAddonsPagePanel } from '@/state/addons-page'
29
30const CustomDomainSidePanel = () => {
31 const { ref: projectRef } = useParams()
32 const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota')
33
34 const [selectedOption, setSelectedOption] = useState<string>('cd_none')
35
36 const { can: canUpdateCustomDomain } = useAsyncCheckPermissions(
37 PermissionAction.BILLING_WRITE,
38 'stripe.subscriptions'
39 )
40
41 const { panel, closePanel } = useAddonsPagePanel()
42 const visible = panel === 'customDomain'
43
44 const { data: addons, isPending: isLoading } = useProjectAddonsQuery({ projectRef })
45 const { mutate: updateAddon, isPending: isUpdating } = useProjectAddonUpdateMutation({
46 onSuccess: () => {
47 toast.success(`Successfully enabled custom domain`)
48 closePanel()
49 },
50 onError: (error) => {
51 toast.error(`Unable to enable custom domain: ${error.message}`)
52 },
53 })
54 const { mutate: removeAddon, isPending: isRemoving } = useProjectAddonRemoveMutation({
55 onSuccess: () => {
56 toast.success(`Successfully disabled custom domain`)
57 closePanel()
58 },
59 onError: (error) => {
60 toast.error(`Unable to disable custom domain: ${error.message}`)
61 },
62 })
63 const isSubmitting = isUpdating || isRemoving
64
65 const subscriptionCDOption = (addons?.selected_addons ?? []).find(
66 (addon) => addon.type === 'custom_domain'
67 )
68 const availableOptions =
69 (addons?.available_addons ?? []).find((addon) => addon.type === 'custom_domain')?.variants ?? []
70
71 const { hasAccess: hasAccessToCustomDomain, isLoading: isLoadingEntitlement } =
72 useCheckEntitlements('custom_domain')
73 const hasChanges = selectedOption !== (subscriptionCDOption?.variant.identifier ?? 'cd_none')
74 const selectedCustomDomain = availableOptions.find(
75 (option) => option.identifier === selectedOption
76 )
77
78 useEffect(() => {
79 if (visible) {
80 if (subscriptionCDOption !== undefined) {
81 setSelectedOption(subscriptionCDOption.variant.identifier)
82 } else {
83 setSelectedOption('cd_none')
84 }
85 }
86 }, [visible, isLoading])
87
88 const onConfirm = async () => {
89 if (!projectRef) return console.error('Project ref is required')
90 if (selectedOption === 'cd_none' && subscriptionCDOption !== undefined) {
91 removeAddon({ projectRef, variant: subscriptionCDOption.variant.identifier })
92 } else {
93 updateAddon({ projectRef, type: 'custom_domain', variant: selectedOption as AddonVariantId })
94 }
95 }
96
97 return (
98 <SidePanel
99 size="large"
100 visible={visible}
101 onCancel={closePanel}
102 onConfirm={onConfirm}
103 loading={isLoading || isSubmitting || isLoadingEntitlement}
104 disabled={
105 !hasAccessToCustomDomain ||
106 isLoadingEntitlement ||
107 isLoading ||
108 !hasChanges ||
109 isSubmitting ||
110 !canUpdateCustomDomain ||
111 // Allow disabling, but do not allow opting in
112 (subscriptionCDOption === undefined && customDomainsDisabledDueToQuota)
113 }
114 tooltip={
115 !hasAccessToCustomDomain
116 ? 'Unable to enable custom domain on a Free Plan'
117 : !canUpdateCustomDomain
118 ? 'You do not have permission to update custom domain'
119 : undefined
120 }
121 header={
122 <div className="flex w-full items-center justify-between">
123 <h4>Custom domains</h4>
124 <DocsButton href={`${DOCS_URL}/guides/platform/custom-domains`} />
125 </div>
126 }
127 >
128 <SidePanel.Content>
129 <div className="py-6 space-y-4">
130 {subscriptionCDOption === undefined &&
131 selectedCustomDomain !== undefined &&
132 customDomainsDisabledDueToQuota && (
133 <Alert variant="default" className="mb-2">
134 <AlertCircle className="h-4 w-4" />
135 <AlertTitle>Adding new custom domains temporarily disabled</AlertTitle>
136 <AlertDescription className="flex flex-col gap-3">
137 We are working with our upstream DNS provider before we are able to sign up new
138 custom domains. Please check back in a few hours.
139 </AlertDescription>
140 </Alert>
141 )}
142 <p className="text-sm">
143 Custom domains allow you to present a branded experience to your users. You may set up
144 your custom domain in the{' '}
145 <InlineLink href={`/project/${projectRef}/settings/general#custom-domains`}>
146 General Settings
147 </InlineLink>{' '}
148 page after enabling the add-on.
149 </p>
150
151 <div className={cn('mt-8! pb-4', !hasAccessToCustomDomain && 'opacity-75')}>
152 <RadioGroupCard
153 id="custom-domain"
154 className="flex flex-wrap gap-3"
155 value={selectedOption}
156 onValueChange={(value) => setSelectedOption(value)}
157 >
158 <RadioGroupCardItem
159 value="cd_none"
160 id="cd_none"
161 label={
162 <div className="w-full group text-left">
163 <div className="border-b border-default px-4 py-2 group-hover:border-control">
164 <p className="text-sm">No custom domain</p>
165 </div>
166 <div className="px-4 py-2">
167 <p className="text-foreground-light">
168 Use the default briven domain for your API
169 </p>
170 <div className="flex items-center space-x-1 mt-2">
171 <p className="text-foreground text-sm" translate="no">
172 $0
173 </p>
174 <p className="text-foreground-light translate-y-px"> / month</p>
175 </div>
176 </div>
177 </div>
178 }
179 showIndicator={false}
180 />
181 {availableOptions.map((option) => (
182 <RadioGroupCardItem
183 key={option.identifier}
184 value={option.identifier}
185 id={option.identifier}
186 label={
187 <div className="w-full group text-left">
188 <div className="border-b border-default px-4 py-2 group-hover:border-control">
189 <p className="text-sm">{option.name}</p>
190 </div>
191 <div className="px-4 py-2">
192 <p className="text-foreground-light">
193 Present a branded experience to your users
194 </p>
195 <div className="flex items-center space-x-1 mt-2">
196 <p className="text-foreground text-sm" translate="no">
197 {formatCurrency(option.price)}
198 </p>
199 <p className="text-foreground-light translate-y-px"> / month</p>
200 </div>
201 </div>
202 </div>
203 }
204 showIndicator={false}
205 />
206 ))}
207 </RadioGroupCard>
208 <TaxDisclaimer className="mt-3" />
209 </div>
210
211 {hasChanges && selectedOption !== 'cd_none' && (
212 <p className="text-sm text-foreground-light">
213 There are no immediate charges. The add-on is billed at the end of your billing cycle
214 based on your usage and prorated to the hour.
215 </p>
216 )}
217
218 {!hasAccessToCustomDomain && (
219 <UpgradeToPro
220 addon="customDomain"
221 source="customDomainSidePanel"
222 featureProposition="enable custom domains"
223 primaryText="Custom domains are a Pro Plan add-on"
224 secondaryText="Enable the add-on to serve your project on your own domain name."
225 />
226 )}
227 </div>
228 </SidePanel.Content>
229 </SidePanel>
230 )
231}
232
233export default CustomDomainSidePanel