From 27bf41b8912d51fc555a7cb6c025db9d831c03a7 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Mon, 26 Aug 2024 17:41:52 +0200 Subject: [PATCH 1/4] Refactor the Subscription modal to match the design. --- .../Subscription/PlanUpdateSidePanel.tsx | 352 ++++++++++-------- packages/ui/src/components/Modal/Modal.tsx | 3 +- 2 files changed, 205 insertions(+), 150 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 36df82b6e8919..0fd552d60d0a1 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { isArray } from 'lodash' -import { ChevronRight, ExternalLink } from 'lucide-react' +import { Box, ChevronRight, ExternalLink, Pause } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' @@ -10,7 +10,6 @@ import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/ import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import InformationBox from 'components/ui/InformationBox' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' @@ -25,24 +24,13 @@ import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Button, - IconCheck, - IconInfo, - Modal, - SidePanel, - cn, -} from 'ui' +import { Alert_Shadcn_, Badge, Button, IconCheck, Modal, SidePanel, WarningIcon, cn } from 'ui' import DowngradeModal from './DowngradeModal' import EnterpriseCard from './EnterpriseCard' import ExitSurveyModal from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' import PaymentMethodSelection from './PaymentMethodSelection' import UpgradeSurveyModal from './UpgradeModal' -import PartnerIcon from 'components/ui/PartnerIcon' const PlanUpdateSidePanel = () => { const router = useRouter() @@ -320,14 +308,14 @@ const PlanUpdateSidePanel = () => { setSelectedTier(undefined)} onConfirm={onUpdateSubscription} dialogOverlayProps={{ className: 'pointer-events-none' }} header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`} > - +
{subscriptionPreviewError && ( { /> )} {subscriptionPreviewIsLoading && ( -
+
Estimating monthly costs... @@ -345,167 +333,235 @@ const PlanUpdateSidePanel = () => { {subscriptionPreviewInitialized && (
Item, - + + Item + , + Usage , - Unit Price, - + + Unit Price + , + Cost , ]} body={ <> - {subscriptionPreview.breakdown.map((item) => ( - <> - - - {item.breakdown && item.breakdown.length > 0 && ( - + Project + Project name + + Nano + Micro + + + + + + {formatCurrency(10)} per month + + + + + {formatCurrency(0)} + + {formatCurrency(10)} + + + + + + Any un-paused projects will be charged for compute hours + + + + +
+ + Project + Project name + + + Paused + +
+
+ + + {formatCurrency(10)} per month + + + + + {formatCurrency(0)} + + {formatCurrency(10)} + + +
+ + + + + Credits + + Up to $10 Compute credits - - {formatCurrency( - Math.round( - subscriptionPreview.breakdown.reduce( - (prev, cur) => prev + cur.total_price, - 0 - ) - ) ?? 0 - )} + + {formatCurrency(-10)} + + + + + + + Costs before upgrade + + + {formatCurrency(0)} + + + + + + + +
+ + New monthly cost + + (Excluding over-usage and credits) +
+
+ + + {formatCurrency(35)} +
} >
- } - defaultVisibility={true} - hideCollapse={true} - description={ -
-

- Each project is a dedicated server and database. Paid plans come with $10 of - Compute Credits to cover one project on the default Micro Compute size or - parts of any compute addon. Additional unpaused projects on paid plans will - incur compute usage costs starting at $10 per month, billed hourly. -

- - {subscription?.plan?.id === 'free' && ( -

+ {subscription?.plan?.id === 'free' && ( + +

+ +
+

Mixing paid and non-paid projects in a single organization is not possible. - If you want projects to be on the Free Plan, use self-serve project - transfers.

- )} - -
- - {subscription?.plan?.id === 'free' && ( - - )} +

+ To keep projects on a free plan, move them to another organization. +

+ +
- } - /> + + )}
)} - +
{!billingViaPartner ? ( -
-

- Upon clicking confirm, your monthly invoice will be adjusted and your credit card +

+ +

+ When upgrading to Pro, the monthly invoice will be adjusted and your credit card will be charged immediately. Changing the plan resets your billing cycle and may - result in a prorated charge for previous usage. + result in a prorated charge for the previous usage.

- -
- -
) : (
diff --git a/packages/ui/src/components/Modal/Modal.tsx b/packages/ui/src/components/Modal/Modal.tsx index b32f81a4a17a9..232dbcb9c8e38 100644 --- a/packages/ui/src/components/Modal/Modal.tsx +++ b/packages/ui/src/components/Modal/Modal.tsx @@ -7,14 +7,13 @@ import { Button, ButtonVariantProps } from '../Button/Button' import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, DialogTrigger, - DialogDescription, - DialogClose, } from '../shadcn/ui/dialog' export interface ModalProps extends React.ComponentProps { From 0f7330b7289367e8ef098e3a041cffa8c9197e67 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 27 Aug 2024 17:54:51 +0200 Subject: [PATCH 2/4] Move instance sizes, providers and default regions to shared-data package. --- .../ProjectCreation/RegionSelector.tsx | 3 +- .../DeployNewReplicaPanel.tsx | 6 +- .../data/projects/project-create-mutation.ts | 2 +- apps/studio/lib/cloudprovider-utils.ts | 2 +- apps/studio/lib/constants/infrastructure.ts | 23 ---- .../[slug]/deploy-button/new-project.tsx | 3 +- apps/studio/pages/new/[slug].tsx | 126 ++---------------- apps/studio/pages/vercel/setupProject.tsx | 10 +- packages/shared-data/index.ts | 13 +- packages/shared-data/instance-sizes.ts | 110 +++++++++++++++ packages/shared-data/package.json | 3 + packages/shared-data/providers.ts | 17 +++ packages/shared-data/regions.ts | 8 +- 13 files changed, 166 insertions(+), 160 deletions(-) create mode 100644 packages/shared-data/instance-sizes.ts create mode 100644 packages/shared-data/providers.ts diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 245b265dfff7b..a6de09ad73262 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -3,8 +3,7 @@ import { ControllerRenderProps, UseFormReturn } from 'react-hook-form' import { useDefaultRegionQuery } from 'data/misc/get-default-region-query' import { useFlag } from 'hooks/ui/useFlag' -import { PROVIDERS } from 'lib/constants' -import type { CloudProvider } from 'shared-data' +import { PROVIDERS, type CloudProvider } from 'shared-data' import { SelectContent_Shadcn_, SelectGroup_Shadcn_, diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index ca5e54e582d86..29228e117611b 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -12,9 +12,9 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { AWS_REGIONS_DEFAULT, BASE_PATH } from 'lib/constants' +import { BASE_PATH } from 'lib/constants' import type { AWS_REGIONS_KEYS } from 'shared-data' -import { AWS_REGIONS } from 'shared-data' +import { AWS_REGIONS, AWS_REGIONS_DEFAULT } from 'shared-data' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -22,8 +22,8 @@ import { Button, Listbox, SidePanel, + WarningIcon, } from 'ui' -import { WarningIcon } from 'ui' import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants' // [Joshen] FYI this is purely for AWS only, need to update to support Fly eventually diff --git a/apps/studio/data/projects/project-create-mutation.ts b/apps/studio/data/projects/project-create-mutation.ts index 8484041d65c8e..53eff4f395aa7 100644 --- a/apps/studio/data/projects/project-create-mutation.ts +++ b/apps/studio/data/projects/project-create-mutation.ts @@ -1,10 +1,10 @@ import * as Sentry from '@sentry/nextjs' import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'react-hot-toast' +import { PROVIDERS } from 'shared-data' import type { components } from 'data/api' import { handleError, post } from 'data/fetchers' -import { PROVIDERS } from 'lib/constants' import type { ResponseError } from 'types' import { projectKeys } from './keys' diff --git a/apps/studio/lib/cloudprovider-utils.ts b/apps/studio/lib/cloudprovider-utils.ts index e40898ff32a87..8848442c079c5 100644 --- a/apps/studio/lib/cloudprovider-utils.ts +++ b/apps/studio/lib/cloudprovider-utils.ts @@ -1,4 +1,4 @@ -import { PROVIDERS } from './constants' +import { PROVIDERS } from 'shared-data' export function getCloudProviderArchitecture(cloudProvider: string | undefined) { switch (cloudProvider) { diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 691c14d28743f..4684f834f4ff6 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -1,14 +1,7 @@ import type { CloudProvider } from 'shared-data' -import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' import type { components } from 'data/api' -export const AWS_REGIONS_DEFAULT = - process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.EAST_US - -// TO DO, change default to US region for prod -export const FLY_REGIONS_DEFAULT = FLY_REGIONS.SOUTHEAST_ASIA - export const PRICING_TIER_LABELS_ORG = { FREE: 'Free - $0/month', PRO: 'Pro - $25/month', @@ -25,22 +18,6 @@ export const PRICING_TIER_PRODUCT_IDS = { export const DEFAULT_PROVIDER: CloudProvider = 'AWS' -export const PROVIDERS = { - FLY: { - id: 'FLY', - name: 'Fly.io', - default_region: FLY_REGIONS_DEFAULT, - regions: { ...FLY_REGIONS }, - }, - AWS: { - id: 'AWS', - name: 'AWS', - DEFAULT_SSH_KEY: 'supabase-app-instance', - default_region: AWS_REGIONS_DEFAULT, - regions: { ...AWS_REGIONS }, - }, -} as const - export const PROJECT_STATUS: { [key: string]: components['schemas']['ResourceWithServicesStatusResponse']['status'] } = { diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index c0cf0bd4ef1b5..e9758403479ad 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -18,10 +18,9 @@ import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-pr import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectCreateMutation } from 'data/projects/project-create-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { PROVIDERS } from 'lib/constants' import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils' import passwordStrength from 'lib/password-strength' -import { AWS_REGIONS } from 'shared-data' +import { AWS_REGIONS, PROVIDERS } from 'shared-data' import { useIntegrationInstallationSnapshot } from 'state/integration-installation' import type { NextPageWithLayout } from 'types' diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index e68ca707a1540..080e40d3f60f3 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -11,7 +11,6 @@ import toast from 'react-hot-toast' import { z } from 'zod' import { PopoverSeparator } from '@ui/components/shadcn/ui/popover' -import { components } from 'api-types' import { useParams } from 'common' import { FreeProjectLimitWarning, @@ -36,16 +35,17 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { withAuth } from 'hooks/misc/withAuth' import { useFlag } from 'hooks/ui/useFlag' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' +import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, DEFAULT_PROVIDER, PROJECT_STATUS } from 'lib/constants' +import passwordStrength from 'lib/password-strength' import { AWS_REGIONS_DEFAULT, - DEFAULT_MINIMUM_PASSWORD_STRENGTH, - DEFAULT_PROVIDER, + DesiredInstanceSize, FLY_REGIONS_DEFAULT, - PROJECT_STATUS, PROVIDERS, -} from 'lib/constants' -import passwordStrength from 'lib/password-strength' -import type { CloudProvider } from 'shared-data' + instanceSizeSpecs, + instanceSizes, + type CloudProvider, +} from 'shared-data' import type { NextPageWithLayout } from 'types' import { Admonition, @@ -79,114 +79,6 @@ import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' -type DesiredInstanceSize = components['schemas']['DesiredInstanceSize'] - -const sizes: DesiredInstanceSize[] = [ - 'micro', - 'small', - 'medium', - 'large', - 'xlarge', - '2xlarge', - '4xlarge', - '8xlarge', - '12xlarge', - '16xlarge', -] - -const instanceSizeSpecs: Record< - DesiredInstanceSize, - { - label: string - ram: string - cpu: string - priceHourly: number - priceMonthly: number - cloud_providers: string[] - } -> = { - micro: { - label: 'Micro', - ram: '1 GB', - cpu: '2-core', - priceHourly: 0.01344, - priceMonthly: 10, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - small: { - label: 'Small', - ram: '2 GB', - cpu: '2-core', - priceHourly: 0.0206, - priceMonthly: 15, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - medium: { - label: 'Medium', - ram: '4 GB', - cpu: '2-core', - priceHourly: 0.0822, - priceMonthly: 60, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - large: { - label: 'Large', - ram: '8 GB', - cpu: '2-core', - priceHourly: 0.1517, - priceMonthly: 110, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - xlarge: { - label: 'XL', - ram: '16 GB', - cpu: '4-core', - priceHourly: 0.2877, - priceMonthly: 210, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - '2xlarge': { - label: '2XL', - ram: '32 GB', - cpu: '8-core', - priceHourly: 0.562, - priceMonthly: 410, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - '4xlarge': { - label: '4XL', - ram: '64 GB', - cpu: '16-core', - priceHourly: 1.32, - priceMonthly: 960, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - '8xlarge': { - label: '8XL', - ram: '128 GB', - cpu: '32-core', - priceHourly: 2.562, - priceMonthly: 1870, - cloud_providers: [PROVIDERS.AWS.id], - }, - '12xlarge': { - label: '12XL', - ram: '192 GB', - cpu: '48-core', - priceHourly: 3.836, - priceMonthly: 2800, - cloud_providers: [PROVIDERS.AWS.id], - }, - '16xlarge': { - label: '16XL', - ram: '256 GB', - cpu: '64-core', - priceHourly: 5.12, - priceMonthly: 3730, - cloud_providers: [PROVIDERS.AWS.id], - }, -} - const Wizard: NextPageWithLayout = () => { const router = useRouter() const { slug } = useParams() @@ -310,7 +202,7 @@ const Wizard: NextPageWithLayout = () => { dbPass: '', dbPassStrength: 0, dbRegion: defaultRegion || undefined, - instanceSize: sizes[0], + instanceSize: instanceSizes[0], dataApi: true, useApiSchema: false, }, @@ -642,7 +534,7 @@ const Wizard: NextPageWithLayout = () => { - {sizes + {instanceSizes .filter((option) => instanceSizeSpecs[option].cloud_providers.includes( form.getValues('cloudProvider') as CloudProvider diff --git a/apps/studio/pages/vercel/setupProject.tsx b/apps/studio/pages/vercel/setupProject.tsx index fa7d07fde2ff2..de5302534617f 100644 --- a/apps/studio/pages/vercel/setupProject.tsx +++ b/apps/studio/pages/vercel/setupProject.tsx @@ -7,7 +7,6 @@ import { ChangeEvent, createContext, useContext, useEffect, useRef, useState } f import { toast } from 'react-hot-toast' import { Button, Input, Listbox } from 'ui' -import type { Dictionary } from 'types' import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout' import { createVercelEnv, @@ -17,14 +16,11 @@ import { import { Loading } from 'components/ui/Loading' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' import { useProjectCreateMutation } from 'data/projects/project-create-mutation' -import { - DEFAULT_MINIMUM_PASSWORD_STRENGTH, - PRICING_TIER_PRODUCT_IDS, - PROVIDERS, -} from 'lib/constants' +import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import passwordStrength from 'lib/password-strength' import { VERCEL_INTEGRATION_CONFIGS } from 'lib/vercelConfigs' -import { AWS_REGIONS } from 'shared-data' +import { AWS_REGIONS, PROVIDERS } from 'shared-data' +import type { Dictionary } from 'types' interface ISetupProjectStore { token: string diff --git a/packages/shared-data/index.ts b/packages/shared-data/index.ts index 20529e456bbc4..a732db14edd1b 100644 --- a/packages/shared-data/index.ts +++ b/packages/shared-data/index.ts @@ -1,24 +1,31 @@ import config from './config' import extensions from './extensions.json' +import { type DesiredInstanceSize, instanceSizes, instanceSizeSpecs } from './instance-sizes' import logConstants from './logConstants' import { plans, PricingInformation } from './plans' import { pricing } from './pricing' import { products } from './products' +import { PROVIDERS } from './providers' import questions from './questions' import type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions' -import { AWS_REGIONS, FLY_REGIONS } from './regions' +import { AWS_REGIONS, AWS_REGIONS_DEFAULT, FLY_REGIONS, FLY_REGIONS_DEFAULT } from './regions' import tweets from './tweets' -export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region } export { AWS_REGIONS, - FLY_REGIONS, + AWS_REGIONS_DEFAULT, config, extensions, + FLY_REGIONS, + FLY_REGIONS_DEFAULT, + instanceSizes, + instanceSizeSpecs, logConstants, plans, pricing, products, + PROVIDERS, questions, tweets, } +export type { AWS_REGIONS_KEYS, CloudProvider, DesiredInstanceSize, PricingInformation, Region } diff --git a/packages/shared-data/instance-sizes.ts b/packages/shared-data/instance-sizes.ts new file mode 100644 index 0000000000000..c1fe891e354f3 --- /dev/null +++ b/packages/shared-data/instance-sizes.ts @@ -0,0 +1,110 @@ +import type { components } from 'api-types' +import { PROVIDERS } from './providers' + +export type DesiredInstanceSize = components['schemas']['DesiredInstanceSize'] + +export const instanceSizes: DesiredInstanceSize[] = [ + 'micro', + 'small', + 'medium', + 'large', + 'xlarge', + '2xlarge', + '4xlarge', + '8xlarge', + '12xlarge', + '16xlarge', +] + +export const instanceSizeSpecs: Record< + DesiredInstanceSize, + { + label: string + ram: string + cpu: string + priceHourly: number + priceMonthly: number + cloud_providers: string[] + } +> = { + micro: { + label: 'Micro', + ram: '1 GB', + cpu: '2-core', + priceHourly: 0.01344, + priceMonthly: 10, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + small: { + label: 'Small', + ram: '2 GB', + cpu: '2-core', + priceHourly: 0.0206, + priceMonthly: 15, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + medium: { + label: 'Medium', + ram: '4 GB', + cpu: '2-core', + priceHourly: 0.0822, + priceMonthly: 60, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + large: { + label: 'Large', + ram: '8 GB', + cpu: '2-core', + priceHourly: 0.1517, + priceMonthly: 110, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + xlarge: { + label: 'XL', + ram: '16 GB', + cpu: '4-core', + priceHourly: 0.2877, + priceMonthly: 210, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + '2xlarge': { + label: '2XL', + ram: '32 GB', + cpu: '8-core', + priceHourly: 0.562, + priceMonthly: 410, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + '4xlarge': { + label: '4XL', + ram: '64 GB', + cpu: '16-core', + priceHourly: 1.32, + priceMonthly: 960, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + '8xlarge': { + label: '8XL', + ram: '128 GB', + cpu: '32-core', + priceHourly: 2.562, + priceMonthly: 1870, + cloud_providers: [PROVIDERS.AWS.id], + }, + '12xlarge': { + label: '12XL', + ram: '192 GB', + cpu: '48-core', + priceHourly: 3.836, + priceMonthly: 2800, + cloud_providers: [PROVIDERS.AWS.id], + }, + '16xlarge': { + label: '16XL', + ram: '256 GB', + cpu: '64-core', + priceHourly: 5.12, + priceMonthly: 3730, + cloud_providers: [PROVIDERS.AWS.id], + }, +} diff --git a/packages/shared-data/package.json b/packages/shared-data/package.json index 9dd0d49e5be49..dd77122bffa45 100644 --- a/packages/shared-data/package.json +++ b/packages/shared-data/package.json @@ -5,6 +5,9 @@ "main": "./index.ts", "types": "./index.ts", "scripts": {}, + "dependencies": { + "api-types": "*" + }, "author": "", "license": "MIT" } diff --git a/packages/shared-data/providers.ts b/packages/shared-data/providers.ts new file mode 100644 index 0000000000000..2a2b306e886ed --- /dev/null +++ b/packages/shared-data/providers.ts @@ -0,0 +1,17 @@ +import { AWS_REGIONS, AWS_REGIONS_DEFAULT, FLY_REGIONS, FLY_REGIONS_DEFAULT } from './regions' + +export const PROVIDERS = { + FLY: { + id: 'FLY', + name: 'Fly.io', + default_region: FLY_REGIONS_DEFAULT, + regions: { ...FLY_REGIONS }, + }, + AWS: { + id: 'AWS', + name: 'AWS', + DEFAULT_SSH_KEY: 'supabase-app-instance', + default_region: AWS_REGIONS_DEFAULT, + regions: { ...AWS_REGIONS }, + }, +} as const diff --git a/packages/shared-data/regions.ts b/packages/shared-data/regions.ts index d4cd259c8df09..504eb9d8d545d 100644 --- a/packages/shared-data/regions.ts +++ b/packages/shared-data/regions.ts @@ -11,7 +11,7 @@ export const AWS_REGIONS = { WEST_EU_3: { code: 'eu-west-3', displayName: 'West EU (Paris)' }, CENTRAL_EU: { code: 'eu-central-1', displayName: 'Central EU (Frankfurt)' }, CENTRAL_EU_2: { code: 'eu-central-2', displayName: 'Central Europe (Zurich)' }, - NORTH_EU: { code: 'eu-north-1', displayName: 'North EU (Stockholm)'}, + NORTH_EU: { code: 'eu-north-1', displayName: 'North EU (Stockholm)' }, SOUTH_ASIA: { code: 'ap-south-1', displayName: 'South Asia (Mumbai)' }, SOUTHEAST_ASIA: { code: 'ap-southeast-1', displayName: 'Southeast Asia (Singapore)' }, NORTHEAST_ASIA: { code: 'ap-northeast-1', displayName: 'Northeast Asia (Tokyo)' }, @@ -25,3 +25,9 @@ export type AWS_REGIONS_KEYS = keyof typeof AWS_REGIONS export const FLY_REGIONS = { SOUTHEAST_ASIA: { code: 'sin', displayName: 'Singapore' }, } as const + +export const AWS_REGIONS_DEFAULT = + process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.EAST_US + +// TO DO, change default to US region for prod +export const FLY_REGIONS_DEFAULT = FLY_REGIONS.SOUTHEAST_ASIA From 9ce3f7c5e472928ee9375988e9059e7514b6a884 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 27 Aug 2024 17:56:03 +0200 Subject: [PATCH 3/4] Add nano specifications. --- packages/shared-data/instance-sizes.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/shared-data/instance-sizes.ts b/packages/shared-data/instance-sizes.ts index c1fe891e354f3..6ec64ef185a40 100644 --- a/packages/shared-data/instance-sizes.ts +++ b/packages/shared-data/instance-sizes.ts @@ -17,7 +17,7 @@ export const instanceSizes: DesiredInstanceSize[] = [ ] export const instanceSizeSpecs: Record< - DesiredInstanceSize, + 'nano' | DesiredInstanceSize, { label: string ram: string @@ -27,6 +27,15 @@ export const instanceSizeSpecs: Record< cloud_providers: string[] } > = { + nano: { + label: 'Micro', + ram: 'Up to 0.5 BG', + cpu: 'Shared', + // Set to 0 because it costs the customer 0$ on their invoice + priceHourly: 0, + priceMonthly: 0, + cloud_providers: [PROVIDERS.AWS.id], + }, micro: { label: 'Micro', ram: '1 GB', From f2c17e589366c969f0c9772614f1749e9d4adfd8 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Thu, 29 Aug 2024 13:28:15 +0200 Subject: [PATCH 4/4] Separate the modal into another file. Connect all data to render properly. --- .../Subscription/PlanUpdateSidePanel.tsx | 362 +------------ .../Subscription/UpgradeDowngradeModal.tsx | 491 ++++++++++++++++++ 2 files changed, 509 insertions(+), 344 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/BillingSettings/Subscription/UpgradeDowngradeModal.tsx diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 0fd552d60d0a1..6005536fc8442 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -1,35 +1,28 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { isArray } from 'lodash' -import { Box, ChevronRight, ExternalLink, Pause } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' -import toast from 'react-hot-toast' -import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils' -import Table from 'components/to-be-cleaned/Table' -import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' import { useProjectsQuery } from 'data/projects/projects-query' import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' -import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types' +import type { OrgPlan } from 'data/subscriptions/types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert_Shadcn_, Badge, Button, IconCheck, Modal, SidePanel, WarningIcon, cn } from 'ui' +import { Button, IconCheck, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import EnterpriseCard from './EnterpriseCard' import ExitSurveyModal from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' -import PaymentMethodSelection from './PaymentMethodSelection' +import { UpgradeDowngradeModal } from './UpgradeDowngradeModal' import UpgradeSurveyModal from './UpgradeModal' const PlanUpdateSidePanel = () => { @@ -41,10 +34,9 @@ const PlanUpdateSidePanel = () => { const [showExitSurvey, setShowExitSurvey] = useState(false) const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false) - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() + const [showDowngradeError, setShowDowngradeError] = useState(false) const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>() - const [usageFeesExpanded, setUsageFeesExpanded] = useState([]) const canUpdateSubscription = useCheckPermissions( PermissionAction.BILLING_WRITE, @@ -70,44 +62,13 @@ const PlanUpdateSidePanel = () => { }) const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug }) const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) - const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( - { - onSuccess: () => { - toast.success(`Successfully updated subscription to ${subscriptionPlanMeta?.name}!`) - setSelectedTier(undefined) - onClose() - window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) - setShowUpgradeSurvey(true) - }, - onError: (error) => { - toast.error(`Unable to update subscription: ${error.message}`) - }, - } - ) - const billingViaPartner = subscription?.billing_via_partner === true const billingPartner = subscription?.billing_partner - const paymentViaInvoice = subscription?.payment_method_type === 'invoice' - - const { - data: subscriptionPreview, - error: subscriptionPreviewError, - isLoading: subscriptionPreviewIsLoading, - isSuccess: subscriptionPreviewInitialized, - } = useOrganizationBillingSubscriptionPreview({ tier: selectedTier, organizationSlug: slug }) const availablePlans: OrgPlan[] = plans?.plans ?? [] const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) - const expandUsageFee = (fee: string) => { - setUsageFeesExpanded([...usageFeesExpanded, fee]) - } - - const collapseUsageFee = (fee: string) => { - setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee)) - } - useEffect(() => { if (visible) { setSelectedTier(undefined) @@ -130,26 +91,6 @@ const PlanUpdateSidePanel = () => { } } - const onUpdateSubscription = async () => { - if (!slug) return console.error('org slug is required') - if (!selectedTier) return console.error('Selected plan is required') - if (!selectedPaymentMethod && !paymentViaInvoice) { - return toast.error('Please select a payment method') - } - - // If the user is downgrading from team, should have spend cap disabled by default - const tier = - subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO - ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier) - : selectedTier - - updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod }) - } - - const planMeta = selectedTier - ? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1]) - : null - return ( <> { projects={orgProjects} /> - setSelectedTier(undefined)} - onConfirm={onUpdateSubscription} - dialogOverlayProps={{ className: 'pointer-events-none' }} - header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`} - > -
- {subscriptionPreviewError && ( - - )} - {subscriptionPreviewIsLoading && ( -
- Estimating monthly costs... - - - -
- )} - {subscriptionPreviewInitialized && ( -
- - Item - , - - Usage - , - - Unit Price - , - - Cost - , - ]} - body={ - <> - - -
- PRO - Organization plan -
-
- - - {formatCurrency(25)} per month - - - - - {formatCurrency(0)} - - {formatCurrency(25)} - - -
- - -
- - Project - Project name - - Nano - Micro - -
-
- - - {formatCurrency(10)} per month - - - - - {formatCurrency(0)} - - {formatCurrency(10)} - - -
- - - Any un-paused projects will be charged for compute hours - - - - -
- - Project - Project name - - - Paused - -
-
- - - {formatCurrency(10)} per month - - - - - {formatCurrency(0)} - - {formatCurrency(10)} - - -
- - - - - Credits - - Up to $10 Compute credits - - - - - {formatCurrency(-10)} - - - - - - - Costs before upgrade - - - {formatCurrency(0)} - - - - - - - -
- - New monthly cost - - (Excluding over-usage and credits) -
-
- - - {formatCurrency(35)} - - -
- - } - >
- - {subscription?.plan?.id === 'free' && ( - -
- -
-

- Mixing paid and non-paid projects in a single organization is not possible. -

-

- To keep projects on a free plan, move them to another organization. -

-
- - -
-
- )} -
- )} -
- - - {!billingViaPartner ? ( -
- -

- When upgrading to Pro, the monthly invoice will be adjusted and your credit card - will be charged immediately. Changing the plan resets your billing cycle and may - result in a prorated charge for the previous usage. -

-
- ) : ( -
-

- This organization is billed through our partner{' '} - {billingPartnerLabel(billingPartner)}.{' '} - {billingPartner === 'aws' ? ( - <>The organization's credit balance will be decreased accordingly. - ) : ( - <>You will be charged by them directly. - )} -

- {billingViaPartner && - billingPartner === 'fly' && - subscriptionPreview?.plan_change_type === 'downgrade' && ( -

- Your organization will be downgraded at the end of your current billing cycle. -

- )} -
- )} -
-
+ { + setSelectedTier(undefined) + onClose() + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + setShowUpgradeSurvey(true) + }} + projects={orgProjects} + /> { + const previousMonthlyPrice = previousPlan.priceMonthly + const nextMonthlyPrice = nextPlan.priceMonthly + // If the price is not a number (enterprise tier), don't render + if (typeof previousMonthlyPrice !== 'number' || typeof nextMonthlyPrice !== 'number') { + return null + } + + return ( + + +
+ + {nextPlan.name} + + Organization plan +
+
+ + + {formatCurrency(nextMonthlyPrice)} per month + + + + + {formatCurrency(previousMonthlyPrice)} + + {formatCurrency(nextMonthlyPrice)} + + +
+ ) +} + +const RunningProjectRow = ({ + project, + previousPlan, + nextPlan, + expanded, + onToggle, +}: { + project: ProjectInfo + previousPlan: PricingInformation + nextPlan: PricingInformation + expanded: boolean + onToggle: () => void +}) => { + // this corrects a situation where the customer upgraded from pro to team but the instance is still nano + const correctedComputeSize = + nextPlan.id !== 'tier_free' && project.infra_compute_size === 'nano' + ? 'micro' + : project.infra_compute_size! + + let instanceSizeBadge = {correctedComputeSize} + let instancePriceBadge = ( + <> + + {formatCurrency(instanceSizeSpecs[correctedComputeSize].priceMonthly)} + + ) + // if the user is upgrading the plan from free + if (previousPlan.id === 'tier_free' && nextPlan.id !== 'tier_free') { + instanceSizeBadge = ( + <> + Nano + Micro + + ) + + instancePriceBadge = ( + <> + + {formatCurrency(instanceSizeSpecs['nano'].priceMonthly)} + + {formatCurrency(instanceSizeSpecs['micro'].priceMonthly)} + + ) + } + // if the user is downgrading to free (shouldn't happen because downgrading to free is handled by another modal) + if (previousPlan.id !== 'tier_free' && nextPlan.id === 'tier_free') { + instanceSizeBadge = ( + <> + {project.infra_compute_size} + Nano + + ) + + instancePriceBadge = ( + <> + + {formatCurrency(instanceSizeSpecs[project.infra_compute_size!].priceMonthly)} + + {formatCurrency(instanceSizeSpecs['nano'].priceMonthly)} + + ) + } + + return ( + + +
+ + Project + {project.name} + {instanceSizeBadge} +
+
+ + + + {instancePriceBadge} + +
+ ) +} + +const SumRow = ({ + projects, + previousPlan, + nextPlan, +}: { + projects: ProjectInfo[] + previousPlan: PricingInformation + nextPlan: PricingInformation +}) => { + const projectsCost = projects + .map((project) => { + // this corrects a situation where the customer upgraded from pro to team but the instance is still nano + const correctedComputeSize = + nextPlan.id !== 'tier_free' && project.infra_compute_size === 'nano' + ? 'micro' + : project.infra_compute_size! + return instanceSizeSpecs[correctedComputeSize].priceMonthly + }) + .reduce((sum, cost) => sum + cost, 0) + + const previousMonthlyPrice = +previousPlan.priceMonthly + projectsCost - AMOUNT_OF_CREDITS + const nextMonthlyPrice = +nextPlan.priceMonthly + projectsCost - AMOUNT_OF_CREDITS + + return ( + <> + + + + + Costs before upgrade + + + {formatCurrency(previousMonthlyPrice)} + + + {/* empty row to separate the dotted lines */} + + + + + +
+ New monthly cost + (Excluding over-usage and credits) +
+
+ + + {formatCurrency(nextMonthlyPrice)} + + +
+ + ) +} + +export const UpgradeDowngradeModal = ({ + subscription, + selectedTier, + setSelectedTier, + availablePlans, + projects, + onSuccess, +}: { + selectedTier: PricingTier + setSelectedTier: Dispatch> + subscription: OrgSubscriptionData | undefined + availablePlans: OrgPlan[] + projects: ProjectInfo[] + onSuccess: () => void +}) => { + const selectedOrganization = useSelectedOrganization() + const slug = selectedOrganization?.slug + + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() + const [usageFeesExpanded, setUsageFeesExpanded] = useState([]) + + const previousPlanMeta = subscriptionsPlans.find( + (tier) => tier.id === `tier_${subscription?.plan.id}` + ) + const nextPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) + + const planMeta = selectedTier + ? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1]) + : null + + const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( + { + onSuccess: () => { + toast.success(`Successfully updated subscription to ${nextPlanMeta?.name}!`) + onSuccess() + }, + onError: (error) => { + toast.error(`Unable to update subscription: ${error.message}`) + }, + } + ) + + const billingViaPartner = subscription?.billing_via_partner === true + const billingPartner = subscription?.billing_partner + const paymentViaInvoice = subscription?.payment_method_type === 'invoice' + + const { + data: subscriptionPreview, + error: subscriptionPreviewError, + isLoading: subscriptionPreviewIsLoading, + isSuccess: subscriptionPreviewInitialized, + } = useOrganizationBillingSubscriptionPreview({ tier: selectedTier, organizationSlug: slug }) + + const onUpdateSubscription = async () => { + if (!slug) return console.error('org slug is required') + if (!selectedTier) return console.error('Selected plan is required') + if (!selectedPaymentMethod && !paymentViaInvoice) { + return toast.error('Please select a payment method') + } + + // If the user is downgrading from team, should have spend cap disabled by default + const tier = + subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO + ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier) + : selectedTier + + updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod }) + } + + return ( + setSelectedTier(undefined)} + onConfirm={onUpdateSubscription} + dialogOverlayProps={{ className: 'pointer-events-none' }} + header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${nextPlanMeta?.name}`} + > +
+ {subscriptionPreviewError && ( + + )} + {subscriptionPreviewIsLoading && ( +
+ Estimating monthly costs... + + + +
+ )} + {subscriptionPreviewInitialized && ( +
+ + Item + , + + Usage + , + + Unit Price + , + + Cost + , + ]} + body={ + <> + + + {projects.map((project) => { + const expanded = usageFeesExpanded.includes(project.name) + return ( + { + if (expanded) { + setUsageFeesExpanded( + usageFeesExpanded.filter((item) => item !== project.name) + ) + } else { + setUsageFeesExpanded([...usageFeesExpanded, project.name]) + } + }} + /> + ) + })} + {/* + + Any un-paused projects will be charged for compute hours + + + + +
+ + Project + Project name + + + Paused + +
+
+ + + {formatCurrency(10)} per month + + + + + {formatCurrency(0)} + + {formatCurrency(10)} + + +
*/} + + + + Credits + Up to $10 Compute credits + + + + + {formatCurrency(-AMOUNT_OF_CREDITS)} + + + + + } + >
+ + {subscription?.plan?.id === 'free' && ( + +
+ +
+

+ Mixing paid and non-paid projects in a single organization is not possible. +

+

+ To keep projects on a free plan, move them to another organization. +

+
+ + +
+
+ )} +
+ )} +
+ + + {!billingViaPartner ? ( +
+ +

+ When upgrading to Pro, the monthly invoice will be adjusted and your credit card will + be charged immediately. Changing the plan resets your billing cycle and may result in + a prorated charge for the previous usage. +

+
+ ) : ( +
+

+ This organization is billed through our partner {billingPartnerLabel(billingPartner)}.{' '} + {billingPartner === 'aws' ? ( + <>The organization's credit balance will be decreased accordingly. + ) : ( + <>You will be charged by them directly. + )} +

+ {billingViaPartner && + billingPartner === 'fly' && + subscriptionPreview?.plan_change_type === 'downgrade' && ( +

+ Your organization will be downgraded at the end of your current billing cycle. +

+ )} +
+ )} +
+
+ ) +}