CustomDomainSidePanel.tsx 9.0 KB

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