mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
import { FC, useEffect, useState } from 'react'
|
|
import { Transition } from '@headlessui/react'
|
|
import { useRouter } from 'next/router'
|
|
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
import { Button, IconHelpCircle, Toggle, Modal } from 'ui'
|
|
|
|
import { useStore, useFlag } from 'hooks'
|
|
import { post, patch } from 'lib/common/fetch'
|
|
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
|
import { getURL } from 'lib/helpers'
|
|
import Divider from 'components/ui/Divider'
|
|
import {
|
|
PaymentSummaryPanel,
|
|
ComputeSizeSelection,
|
|
PITRDurationSelection,
|
|
CustomDomainSelection,
|
|
StripeSubscription,
|
|
AddNewPaymentMethodModal,
|
|
} from './'
|
|
import { STRIPE_PRODUCT_IDS } from 'lib/constants'
|
|
import UpdateSuccess from './UpdateSuccess'
|
|
import { PaymentMethod, SubscriptionPreview } from './Billing.types'
|
|
import { formSubscriptionUpdatePayload, getCurrentAddons } from './Billing.utils'
|
|
import { SubscriptionAddon } from './AddOns/AddOns.types'
|
|
import {
|
|
formatComputeSizes,
|
|
formatCustomDomainOptions,
|
|
formatPITROptions,
|
|
} from './AddOns/AddOns.utils'
|
|
import BackButton from 'components/ui/BackButton'
|
|
import SupportPlan from './AddOns/SupportPlan'
|
|
|
|
// Do not allow compute size changes for af-south-1
|
|
|
|
interface Props {
|
|
products: { tiers: any[]; addons: SubscriptionAddon[] }
|
|
paymentMethods?: PaymentMethod[]
|
|
currentSubscription: StripeSubscription
|
|
isLoadingPaymentMethods: boolean
|
|
onSelectBack: () => void
|
|
}
|
|
|
|
const ProUpgrade: FC<Props> = ({
|
|
products,
|
|
paymentMethods,
|
|
currentSubscription,
|
|
isLoadingPaymentMethods,
|
|
onSelectBack,
|
|
}) => {
|
|
const { app, ui } = useStore()
|
|
const router = useRouter()
|
|
const isCustomDomainsEnabled = useFlag('customDomains')
|
|
const isPITRSelfServeEnabled = useFlag('pitrSelfServe')
|
|
|
|
const { addons } = products
|
|
const computeSizes = formatComputeSizes(addons)
|
|
const pitrDurationOptions = formatPITROptions(addons)
|
|
const customDomainOptions = formatCustomDomainOptions(addons)
|
|
const currentAddons = getCurrentAddons(currentSubscription, addons)
|
|
|
|
const projectId = ui.selectedProject?.id ?? -1
|
|
const projectRef = ui.selectedProject?.ref
|
|
const projectRegion = ui.selectedProject?.region ?? ''
|
|
|
|
const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(
|
|
// If project is currently free, default to enabling spend caps
|
|
currentSubscription.tier.prod_id === STRIPE_PRODUCT_IDS.FREE ||
|
|
// Otherwise, Pro plan implies spend caps enabled
|
|
currentSubscription.tier.prod_id === STRIPE_PRODUCT_IDS.PRO
|
|
)
|
|
|
|
const [isRefreshingPreview, setIsRefreshingPreview] = useState(false)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isSuccessful, setIsSuccessful] = useState(false)
|
|
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false)
|
|
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
|
|
|
|
// [Joshen TODO] Ideally we just have a state to hold all the add ons selection, rather than individual
|
|
// Even better if we can just use the <Form> component to handle all of these. Mainly to reduce the amount
|
|
// of unnecessary state management on this complex page.
|
|
const [selectedComputeSize, setSelectedComputeSize] = useState<SubscriptionAddon>(
|
|
currentAddons.computeSize
|
|
)
|
|
const [selectedPITRDuration, setSelectedPITRDuration] = useState<SubscriptionAddon>(
|
|
currentAddons.pitrDuration
|
|
)
|
|
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<SubscriptionAddon>(
|
|
currentAddons.customDomains
|
|
)
|
|
|
|
// [Joshen TODO] Future - We may need to also include any add ons outside of
|
|
// compute size, pitr, custom domain and support plan, although we dont have any now
|
|
const nonChangeableAddons = [currentAddons.supportPlan].filter(
|
|
(x) => x !== undefined
|
|
) as SubscriptionAddon[]
|
|
|
|
// [Joshen] Scaffolded here
|
|
const selectedAddons = {
|
|
computeSize: selectedComputeSize,
|
|
pitrDuration: selectedPITRDuration,
|
|
customDomains: selectedCustomDomainOption,
|
|
}
|
|
|
|
const [selectedPaymentMethodId, setSelectedPaymentMethodId] = useState<string>('')
|
|
const [subscriptionPreview, setSubscriptionPreview] = useState<SubscriptionPreview>()
|
|
|
|
const selectedTier = isSpendCapEnabled
|
|
? products?.tiers.find((tier: any) => tier.id === STRIPE_PRODUCT_IDS.PRO)
|
|
: products?.tiers.find((tier: any) => tier.id === STRIPE_PRODUCT_IDS.PAYG)
|
|
|
|
const isManagingProSubscription =
|
|
currentSubscription.tier.prod_id === STRIPE_PRODUCT_IDS.PRO ||
|
|
currentSubscription.tier.prod_id === STRIPE_PRODUCT_IDS.PAYG
|
|
|
|
const isChangingComputeSize = currentAddons.computeSize?.id !== selectedAddons.computeSize.id
|
|
|
|
useEffect(() => {
|
|
if (!isLoadingPaymentMethods && paymentMethods && paymentMethods.length > 0) {
|
|
setSelectedPaymentMethodId(paymentMethods[0].id)
|
|
}
|
|
}, [isLoadingPaymentMethods, paymentMethods])
|
|
|
|
useEffect(() => {
|
|
getSubscriptionPreview()
|
|
}, [selectedComputeSize, selectedPITRDuration, selectedCustomDomainOption, isSpendCapEnabled])
|
|
|
|
const getSubscriptionPreview = async () => {
|
|
if (!selectedTier) return
|
|
|
|
const payload = formSubscriptionUpdatePayload(
|
|
currentSubscription,
|
|
selectedTier,
|
|
selectedAddons,
|
|
nonChangeableAddons,
|
|
selectedPaymentMethodId,
|
|
projectRegion
|
|
)
|
|
|
|
setIsRefreshingPreview(true)
|
|
const preview = await post(`${API_URL}/projects/${projectRef}/subscription/preview`, payload)
|
|
if (preview.error) {
|
|
ui.setNotification({
|
|
category: 'error',
|
|
message: `Failed to fetch subscription preview: ${preview.error.message}`,
|
|
})
|
|
}
|
|
setSubscriptionPreview(preview)
|
|
setIsRefreshingPreview(false)
|
|
}
|
|
|
|
const onConfirmPayment = async () => {
|
|
const payload = formSubscriptionUpdatePayload(
|
|
currentSubscription,
|
|
selectedTier,
|
|
selectedAddons,
|
|
nonChangeableAddons,
|
|
selectedPaymentMethodId,
|
|
projectRegion
|
|
)
|
|
|
|
setIsSubmitting(true)
|
|
const res = await patch(`${API_URL}/projects/${projectRef}/subscription`, payload)
|
|
if (res?.error) {
|
|
ui.setNotification({
|
|
category: 'error',
|
|
message: `Failed to update subscription: ${res?.error?.message}`,
|
|
})
|
|
} else {
|
|
if (isChangingComputeSize) {
|
|
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
|
|
ui.setNotification({
|
|
category: 'success',
|
|
message:
|
|
'Your project has been updated and is currently restarting to update its instance size',
|
|
duration: 8000,
|
|
})
|
|
router.push(`/project/${projectRef}`)
|
|
} else {
|
|
setIsSuccessful(true)
|
|
}
|
|
}
|
|
setIsSubmitting(false)
|
|
}
|
|
|
|
if (!isSubmitting && isSuccessful) {
|
|
return (
|
|
<UpdateSuccess
|
|
projectRef={projectRef || ''}
|
|
title="Your project has been updated!"
|
|
message="Let us know if you have any feedback at sales@supabase.io."
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Transition
|
|
show
|
|
enter="transition ease-out duration-300"
|
|
enterFrom="transform opacity-0 translate-x-10"
|
|
enterTo="transform opacity-100 translate-x-0"
|
|
className="flex w-full items-start justify-between"
|
|
>
|
|
<div className="2xl:min-w-5xl mx-auto mt-10 px-32">
|
|
<div className="relative space-y-4">
|
|
<BackButton onClick={() => onSelectBack()} />
|
|
<div className="space-y-8">
|
|
<h4 className="text-lg text-scale-900">Change your project's subscription</h4>
|
|
<div
|
|
className="space-y-8 overflow-scroll pb-8"
|
|
style={{ height: 'calc(100vh - 9rem - 57px)' }}
|
|
>
|
|
<div className="space-y-2">
|
|
{!isManagingProSubscription ? (
|
|
<>
|
|
<h3 className="text-xl">
|
|
Welcome to <span className="text-brand-900">Pro</span>
|
|
</h3>
|
|
<p className="text-base text-scale-1100">
|
|
Your new subscription will begin immediately after payment
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h3 className="text-3xl">
|
|
Managing your <span className="text-brand-900">Pro</span> plan
|
|
</h3>
|
|
{/* <p className="text-base text-scale-1100">
|
|
Your billing cycle will reset after payment
|
|
</p> */}
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between gap-16 rounded border border-panel-border-light border-panel-border-dark bg-panel-body-light px-6 py-4 drop-shadow-sm dark:bg-panel-body-dark">
|
|
<div>
|
|
<div className="flex items-center space-x-2">
|
|
<p>Enable spend cap</p>
|
|
<IconHelpCircle
|
|
size={16}
|
|
strokeWidth={1.5}
|
|
className="cursor-pointer opacity-50 transition hover:opacity-100"
|
|
onClick={() => setShowSpendCapHelperModal(true)}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-scale-1100">
|
|
If enabled, additional resources will not be charged on a per-usage basis
|
|
</p>
|
|
</div>
|
|
<Toggle
|
|
checked={isSpendCapEnabled}
|
|
onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)}
|
|
/>
|
|
</div>
|
|
{projectRegion !== 'af-south-1' && (
|
|
<>
|
|
{currentAddons.supportPlan !== undefined && (
|
|
<>
|
|
<Divider light />
|
|
<SupportPlan currentOption={currentAddons.supportPlan} />
|
|
</>
|
|
)}
|
|
{isCustomDomainsEnabled && customDomainOptions.length > 0 && (
|
|
<>
|
|
<Divider light />
|
|
<CustomDomainSelection
|
|
options={customDomainOptions}
|
|
currentOption={
|
|
isManagingProSubscription ? currentAddons.customDomains : undefined
|
|
}
|
|
selectedOption={selectedAddons.customDomains}
|
|
onSelectOption={setSelectedCustomDomainOption}
|
|
/>
|
|
</>
|
|
)}
|
|
{isPITRSelfServeEnabled && pitrDurationOptions.length > 0 && (
|
|
<>
|
|
<Divider light />
|
|
<PITRDurationSelection
|
|
pitrDurationOptions={pitrDurationOptions}
|
|
currentPitrDuration={
|
|
isManagingProSubscription ? currentAddons.pitrDuration : undefined
|
|
}
|
|
selectedPitrDuration={selectedAddons.pitrDuration}
|
|
onSelectOption={setSelectedPITRDuration}
|
|
/>
|
|
</>
|
|
)}
|
|
<Divider light />
|
|
<ComputeSizeSelection
|
|
computeSizes={computeSizes || []}
|
|
currentComputeSize={currentAddons.computeSize}
|
|
selectedComputeSize={selectedAddons.computeSize}
|
|
onSelectOption={setSelectedComputeSize}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="w-[34rem]">
|
|
<PaymentSummaryPanel
|
|
isRefreshingPreview={isRefreshingPreview}
|
|
subscriptionPreview={subscriptionPreview}
|
|
isSpendCapEnabled={isSpendCapEnabled}
|
|
// Current subscription configuration based on DB
|
|
currentPlan={currentSubscription.tier}
|
|
currentAddons={currentAddons}
|
|
currentSubscription={currentSubscription}
|
|
// Selected subscription configuration based on UI
|
|
selectedPlan={selectedTier}
|
|
selectedAddons={selectedAddons}
|
|
paymentMethods={paymentMethods}
|
|
isLoadingPaymentMethods={isLoadingPaymentMethods}
|
|
selectedPaymentMethod={selectedPaymentMethodId}
|
|
onSelectPaymentMethod={setSelectedPaymentMethodId}
|
|
onSelectAddNewPaymentMethod={() => {
|
|
setShowAddPaymentMethodModal(true)
|
|
}}
|
|
onConfirmPayment={onConfirmPayment}
|
|
isSubmitting={isSubmitting}
|
|
/>
|
|
</div>
|
|
</Transition>
|
|
|
|
<AddNewPaymentMethodModal
|
|
visible={showAddPaymentMethodModal}
|
|
returnUrl={`${getURL()}/project/${projectRef}/settings/billing/update/pro`}
|
|
onCancel={() => setShowAddPaymentMethodModal(false)}
|
|
/>
|
|
|
|
{/* Spend caps helper modal */}
|
|
<Modal
|
|
hideFooter
|
|
visible={showSpendCapHelperModal}
|
|
size="large"
|
|
header="Enabling spend cap"
|
|
onCancel={() => setShowSpendCapHelperModal(false)}
|
|
>
|
|
<div className="space-y-4 py-4">
|
|
<Modal.Content>
|
|
<div className="space-y-4">
|
|
<p className="text-sm">
|
|
A spend cap allows you to restrict your project's resource usage within the limits
|
|
of the Pro tier. Disabling the spend cap will then remove those limits and any
|
|
additional resources consumed beyond the Pro tier limits will be charged on a
|
|
per-usage basis
|
|
</p>
|
|
<p className="text-sm">
|
|
The table below shows an overview of which resources are chargeable, and how they
|
|
are charged:
|
|
</p>
|
|
{/* Maybe instead of a table, show something more interactive like a spend cap playground */}
|
|
{/* Maybe ideate this in Figma first but this is good enough for now */}
|
|
<div className="rounded border border-scale-600 bg-scale-500">
|
|
<div className="flex items-center px-4 pt-2 pb-1">
|
|
<p className="w-[50%] text-sm text-scale-1100">Item</p>
|
|
<p className="w-[25%] text-sm text-scale-1100">Limit</p>
|
|
<p className="w-[25%] text-sm text-scale-1100">Rate</p>
|
|
</div>
|
|
<div className="py-1">
|
|
<div className="flex items-center px-4 py-1">
|
|
<p className="w-[50%] text-sm">Database space</p>
|
|
<p className="w-[25%] text-sm">8GB</p>
|
|
<p className="w-[25%] text-sm">$0.125/GB</p>
|
|
</div>
|
|
<div className="flex items-center px-4 py-1">
|
|
<p className="w-[50%] text-sm">Data transfer</p>
|
|
<p className="w-[25%] text-sm">50GB</p>
|
|
<p className="w-[25%] text-sm">$0.09/GB</p>
|
|
</div>
|
|
</div>
|
|
<div className="py-1">
|
|
<div className="flex items-center px-4 py-1">
|
|
<div className="flex w-[50%] items-center space-x-2">
|
|
<p className="text-sm">Auth MAUs</p>
|
|
<Tooltip.Root delayDuration={0}>
|
|
<Tooltip.Trigger>
|
|
<IconHelpCircle
|
|
size={16}
|
|
strokeWidth={1.5}
|
|
className="cursor-pointer opacity-50 transition hover:opacity-100"
|
|
/>
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content side="bottom">
|
|
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
|
<div
|
|
className={[
|
|
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
|
|
'border border-scale-200 ', //border
|
|
].join(' ')}
|
|
>
|
|
<span className="text-xs text-scale-1200">
|
|
Monthly Active Users: A user that has made an API request in the last
|
|
month
|
|
</span>
|
|
</div>
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
</div>
|
|
<p className="w-[25%] text-sm">100,000</p>
|
|
<p className="w-[25%] text-sm">$0.00325/user</p>
|
|
</div>
|
|
</div>
|
|
<div className="py-1">
|
|
<div className="flex items-center px-4 py-1">
|
|
<p className="w-[50%] text-sm">Storage size</p>
|
|
<p className="w-[25%] text-sm">100GB</p>
|
|
<p className="w-[25%] text-sm">$0.021/GB</p>
|
|
</div>
|
|
<div className="flex items-center px-4 py-1">
|
|
<p className="w-[50%] text-sm">Data Transfer</p>
|
|
<p className="w-[25%] text-sm">50GB</p>
|
|
<p className="w-[25%] text-sm">$0.09/GB</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal.Content>
|
|
<Modal.Separator />
|
|
<Modal.Content>
|
|
<div className="flex items-center gap-2">
|
|
<Button block type="primary" onClick={() => setShowSpendCapHelperModal(false)}>
|
|
Understood
|
|
</Button>
|
|
</div>
|
|
</Modal.Content>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default ProUpgrade
|