diff --git a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 40c333bf496..4f26eeb077b 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { FC } from 'react' -import { Button, IconPlus } from 'ui' +import { Badge, Button, IconPlus } from 'ui' import { observer } from 'mobx-react-lite' import { IS_PLATFORM } from 'lib/constants' @@ -28,7 +28,7 @@ const ProjectList: FC = ({ rewriteHref }) => { return ( <> {organizations.list().map((org: Organization) => { - const { id, name, slug } = org + const { id, name, slug, subscription_id } = org const sortedProjects = projects.list( ({ organization_id }: Project) => organization_id == id ) @@ -40,7 +40,7 @@ const ProjectList: FC = ({ rewriteHref }) => { return (
-

{name}

+

{name}{subscription_id ? V2 : <>}

{!!overdueInvoices.length && (
diff --git a/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx new file mode 100644 index 00000000000..fa0640e50c5 --- /dev/null +++ b/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -0,0 +1,334 @@ +import { useState } from 'react' +import { Button, IconEdit2, IconInfo, Input, Listbox } from 'ui' +import { useRouter } from 'next/router' + +import { API_URL, BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants' +import { useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import Panel from 'components/ui/Panel' +import { useElements, useStripe, PaymentElement } from '@stripe/react-stripe-js' +import { PaymentMethod } from 'components/interfaces/Billing/Billing.types' +import { getURL } from 'lib/helpers' +import InformationBox from 'components/ui/InformationBox' + +const ORG_KIND_TYPES = { + PERSONAL: 'Personal', + EDUCATIONAL: 'Educational', + STARTUP: 'Startup', + AGENCY: 'Agency', + COMPANY: 'Company', + UNDISCLOSED: 'N/A', +} +const ORG_KIND_DEFAULT = 'PERSONAL' + +const ORG_SIZE_TYPES = { + '1': '1 - 10', + '10': '10 - 49', + '50': '50 - 99', + '100': '100 - 299', + '300': 'More than 300', +} +const ORG_SIZE_DEFAULT = '1' + +interface NewOrgFormProps { + onPaymentMethodReset: () => void +} + +/** + * No org selected yet, create a new one + */ +const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { + const { ui, app } = useStore() + const router = useRouter() + const stripe = useStripe() + const elements = useElements() + + const [orgName, setOrgName] = useState('') + const [orgKind, setOrgKind] = useState(ORG_KIND_DEFAULT) + const [orgSize, setOrgSize] = useState(ORG_SIZE_DEFAULT) + const [newOrgLoading, setNewOrgLoading] = useState(false) + const [paymentMethod, setPaymentMethod] = useState() + + const [dbPricingTierKey, setDbPricingTierKey] = useState('PRO') + + function validateOrgName(name: any) { + const value = name ? name.trim() : '' + return value.length >= 1 + } + + function onOrgNameChange(e: any) { + setOrgName(e.target.value) + } + + function onOrgKindChange(value: any) { + setOrgKind(value) + } + + function onOrgSizeChange(value: any) { + setOrgSize(value) + } + + function onDbPricingPlanChange(value: string) { + setDbPricingTierKey(value) + } + + async function createOrg(paymentMethodId: string) { + const response = await post( + `${API_URL}/organizations`, + { + name: orgName, + kind: orgKind, + payment_method: paymentMethodId, + tier: 'tier_' + dbPricingTierKey.toLowerCase(), + ...(orgKind == 'COMPANY' ? { size: orgSize } : {}), + }, + // Call new V2 endpoint from API + { + headers: { + Version: '2', + }, + } + ) + + if (response.error) { + ui.setNotification({ + category: 'error', + message: `Failed to create organization: ${response.error?.message ?? response.error}`, + }) + } else { + const org = response + app.onOrgAdded(org) + router.push(`/new/${org.slug}`) + } + setNewOrgLoading(false) + } + + const handleSubmit = async (event: any) => { + event.preventDefault() + + const isOrgNameValid = validateOrgName(orgName) + if (!isOrgNameValid) { + ui.setNotification({ category: 'error', message: 'Organization name is empty' }) + return + } + + if (!stripe || !elements) { + console.error('Stripe.js has not loaded') + return + } + setNewOrgLoading(true) + + if (!paymentMethod) { + const { error, setupIntent } = await stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: `${getURL()}/new-with-subscription`, + expand: ['payment_method'], + }, + }) + + if (error || !setupIntent.payment_method) { + ui.setNotification({ + category: 'error', + message: error?.message ?? ' Failed to save card details', + }) + setNewOrgLoading(false) + return + } + + const paymentMethodFromSetup = setupIntent.payment_method as PaymentMethod + + setPaymentMethod(paymentMethodFromSetup) + await createOrg(paymentMethodFromSetup.id) + } else { + await createOrg(paymentMethod.id) + } + } + + const resetPaymentMethod = () => { + setPaymentMethod(undefined) + return onPaymentMethodReset() + } + + return ( + <> +
+ +

Create a new organization

+
+ } + footer={ +
+ +
+

You can rename your organization later

+ +
+
+ } + > + + } + defaultVisibility={true} + hideCollapse + title="Billed via organization" + description={ +
+

+ This is heavy Work-In-Progress and not customer facing yet, use with caution! + This organization will use the new org level billing, instead of having + individual subscriptions per project. There are still a lot of open ends that + may be restrictive for you, follow #team-billing for updates. +

+
+ } + /> +
+ + +

This is your organization within Supabase.

+

+ For example, you can use the name of your company or department. +

+
+ + + + + + {Object.entries(ORG_KIND_TYPES).map(([k, v]) => { + return ( + + {v} + + ) + })} + + + + {orgKind == 'COMPANY' ? ( + + + {Object.entries(ORG_SIZE_TYPES).map(([k, v]) => { + return ( + + {v} + + ) + })} + + + ) : ( + <> + )} + + + + Select a plan that suits your needs.  + + More details + + + } + > + {Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => { + return ( + + {v} + + ) + })} + + + + + {paymentMethod ? ( +
+
+ Card + +

+ Expires: {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} +

+
+
+
+
+ ) : ( + + )} +
+ + + + ) +} + +export default NewOrgForm diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index 08d77572274..beee07cbc43 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -2,5 +2,6 @@ import GeneralSettings from './GeneralSettings/GeneralSettings' import TeamSettings from './TeamSettings/TeamSettings' import BillingSettings from './BillingSettings/BillingSettings' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' +import NewOrgForm from './NewOrg/NewOrgForm' -export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings } +export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings, NewOrgForm } diff --git a/studio/components/layouts/AccountLayout/AccountLayout.tsx b/studio/components/layouts/AccountLayout/AccountLayout.tsx index af70d1570c7..b503f308268 100644 --- a/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -37,7 +37,7 @@ const AccountLayout: FC = ({ children, title, breadcrumbs }) => { .map((organization) => ({ isActive: router.pathname.startsWith('/org/') && ui.selectedOrganization?.slug === organization.slug, - label: organization.name, + label: organization.name + (organization.subscription_id ? ' (V2)' : ''), href: `/org/${organization.slug}/general`, key: organization.slug, })) diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx index 40eb7e50fc7..e67571ac6b7 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/OrgDropdown.tsx @@ -3,7 +3,7 @@ import { toJS } from 'mobx' import { observer } from 'mobx-react-lite' import { Button, Dropdown, IconPlus } from 'ui' -import { useStore } from 'hooks' +import { useFlag, useStore } from 'hooks' import { IS_PLATFORM } from 'lib/constants' const OrgDropdown = () => { @@ -13,6 +13,8 @@ const OrgDropdown = () => { const sortedOrganizations: any[] = app.organizations.list() const selectedOrganization: any = ui.selectedOrganization + const orgCreationV2 = useFlag('orgcreationv2') + return IS_PLATFORM ? ( { } onClick={() => router.push(`/new`)}> New organization + {orgCreationV2 && ( + } + onClick={() => router.push(`/new-with-subscription`)} + > + New organization V2 + + )} } > diff --git a/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js b/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js index f3d6eff7321..868b1e3ed68 100644 --- a/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js +++ b/studio/components/to-be-cleaned/Dropdown/OrganizationDropdown.js @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { toJS } from 'mobx' import { Button, Dropdown, IconPlus } from 'ui' import { observer } from 'mobx-react-lite' +import { useFlag } from 'hooks' const OrganizationDropdown = ({ organizations }) => { const router = useRouter() @@ -11,6 +12,8 @@ const OrganizationDropdown = ({ organizations }) => { a.name.localeCompare(b.name) ) + const orgCreationV2 = useFlag('orgcreationv2') + return ( { } onClick={() => router.push(`/new`)}> New organization + {orgCreationV2 && ( + } + onClick={() => router.push(`/new-with-subscription`)} + > + New organization V2 + + )} } > diff --git a/studio/lib/constants/infrastructure.ts b/studio/lib/constants/infrastructure.ts index 437fff21808..1eae4921afe 100644 --- a/studio/lib/constants/infrastructure.ts +++ b/studio/lib/constants/infrastructure.ts @@ -26,6 +26,11 @@ export const PRICING_TIER_LABELS = { PRO: 'Pro', } +export const PRICING_TIER_LABELS_ORG = { + PRO: 'Pro - $25/month', + TEAM: 'Team - $599/month', +} + export const PRICING_TIER_PRODUCT_IDS = { FREE: 'tier_free', PRO: 'tier_pro', diff --git a/studio/pages/new-with-subscription.tsx b/studio/pages/new-with-subscription.tsx new file mode 100644 index 00000000000..abc5597b09c --- /dev/null +++ b/studio/pages/new-with-subscription.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from 'react' +import { observer } from 'mobx-react-lite' + +import { API_URL, STRIPE_PUBLIC_KEY } from 'lib/constants' +import { useFlag, useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { WizardLayout } from 'components/layouts' +import { NextPageWithLayout } from 'types' +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' +import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' +import HCaptcha from '@hcaptcha/react-hcaptcha' +import { NewOrgForm } from 'components/interfaces/Organization' +import { useTheme } from 'common' +import { useRouter } from 'next/router' + +const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) + +/** + * No org selected yet, create a new one + */ +const Wizard: NextPageWithLayout = () => { + const { ui } = useStore() + const router = useRouter() + + const [intent, setIntent] = useState() + const captchaLoaded = useIsHCaptchaLoaded() + + const [captchaToken, setCaptchaToken] = useState(null) + const [captchaRef, setCaptchaRef] = useState(null) + + const orgCreationV2 = useFlag('orgcreationv2') + + const { isDarkMode } = useTheme() + + const captchaRefCallback = useCallback((node) => { + setCaptchaRef(node) + }, []) + + useEffect(() => { + if (orgCreationV2 === false) { + router.push('/new') + } + }, [orgCreationV2]) + + const setupIntent = async (hcaptchaToken: string | undefined) => { + // Force a reload of Elements, necessary for Stripe + setIntent(undefined) + + const intent = await post(`${API_URL}/stripe/setup-intent`, { + hcaptchaToken, + }) + + if (intent.error) { + return ui.setNotification({ + category: 'error', + message: intent.error.message, + error: intent.error, + }) + } else { + setIntent(intent) + } + } + + const options = { + clientSecret: intent ? intent.client_secret : '', + appearance: { theme: isDarkMode ? 'night' : 'flat', labels: 'floating' }, + } as any + + const loadPaymentForm = async () => { + if (captchaRef && captchaLoaded) { + let token = captchaToken + + try { + if (!token) { + const captchaResponse = await captchaRef.execute({ async: true }) + token = captchaResponse?.response ?? null + } + } catch (error) { + return + } + + await setupIntent(token ?? undefined) + resetCaptcha() + } + } + + useEffect(() => { + loadPaymentForm() + }, [captchaRef, captchaLoaded]) + + const resetSetupIntent = () => { + return loadPaymentForm() + } + + const onLocalCancel = () => { + setIntent(undefined) + } + + const resetCaptcha = () => { + setCaptchaToken(null) + captchaRef?.resetCaptcha() + } + + return ( + <> + { + setCaptchaToken(token) + }} + onClose={onLocalCancel} + onExpire={() => { + setCaptchaToken(null) + }} + /> + + {intent && ( + + resetSetupIntent()} /> + + )} + + ) +} + +Wizard.getLayout = (page) => ( + + {page} + +) + +export default observer(Wizard) diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index aab452b7e6a..5f06682e18d 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -4,7 +4,17 @@ import { debounce, isUndefined, values } from 'lodash' import { toJS } from 'mobx' import { observer } from 'mobx-react-lite' import generator from 'generate-password' -import { Button, Listbox, IconUsers, Input, Alert, IconHelpCircle, Toggle } from 'ui' +import { + Button, + Listbox, + IconUsers, + Input, + Alert, + IconHelpCircle, + Toggle, + IconAlertCircle, + IconInfo, +} from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' @@ -35,6 +45,7 @@ import { EmptyPaymentMethodWarning, } from 'components/interfaces/Organization/NewProject' import SpendCapModal from 'components/interfaces/Billing/SpendCapModal' +import InformationBox from 'components/ui/InformationBox' const Wizard: NextPageWithLayout = () => { const router = useRouter() @@ -63,7 +74,7 @@ const Wizard: NextPageWithLayout = () => { const organizations = values(toJS(app.organizations.list())) const currentOrg = organizations.find((o: any) => o.slug === slug) - const stripeCustomerId = currentOrg?.stripe_customer_id + const billedViaOrg = Boolean(currentOrg?.subscription_id) const availableRegions = getAvailableRegions() const isAdmin = checkPermissions(PermissionAction.CREATE, 'projects') @@ -290,6 +301,24 @@ const Wizard: NextPageWithLayout = () => { )} + {billedViaOrg && ( + } + defaultVisibility={true} + hideCollapse + title="Billed via organization" + description={ +
+

+ This is heavy Work-In-Progress and not customer facing yet, use with + caution! This organization uses the new org level billing, instead of having + individual subscriptions per project. +

+
+ } + /> + )} + {!isAdmin && } @@ -397,7 +426,7 @@ const Wizard: NextPageWithLayout = () => { )} - {isAdmin && ( + {isAdmin && !billedViaOrg && (