import { Elements } from '@stripe/react-stripe-js' import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' import _ from 'lodash' import { ExternalLink, HelpCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { z } from 'zod' import { loadStripe } from '@stripe/stripe-js' import { LOCAL_STORAGE_KEYS } from 'common' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' import SpendCapModal from 'components/interfaces/Billing/SpendCapModal' import Panel from 'components/ui/Panel' import { useOrganizationCreateMutation } from 'data/organizations/organization-create-mutation' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' import { useProjectsQuery } from 'data/projects/projects-query' import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from 'lib/constants' import { useProfile } from 'lib/profile' import { useTheme } from 'next-themes' import { Button, Input_Shadcn_, Label_Shadcn_, Select_Shadcn_, SelectContent_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, Switch, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { NewPaymentMethodElement, type PaymentMethodElementRef, } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' 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 setupIntent?: SetupIntentResponse onPlanSelected: (plan: string) => void } const plans = ['FREE', 'PRO', 'TEAM'] as const const formSchema = z.object({ plan: z .string() .transform((val) => val.toUpperCase()) .pipe(z.enum(plans)), name: z.string().min(1), kind: z .string() .transform((val) => val.toUpperCase()) .pipe( z.enum(['PERSONAL', 'EDUCATIONAL', 'STARTUP', 'AGENCY', 'COMPANY', 'UNDISCLOSED'] as const) ), size: z.enum(['1', '10', '50', '100', '300'] as const), spend_cap: z.boolean(), }) type FormState = z.infer const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) const newMandatoryAddressInput = true /** * No org selected yet, create a new one * [Joshen] Need to refactor to use Form_Shadcn here */ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOrgFormProps) => { const router = useRouter() const user = useProfile() const { resolvedTheme } = useTheme() const { data: organizations, isSuccess } = useOrganizationsQuery() const { data } = useProjectsQuery() const [lastVisitedOrganization] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, '' ) const freeOrgs = (organizations || []).filter((it) => it.plan.id === 'free') const projectsByOrg = useMemo(() => { return _.groupBy(data?.projects ?? [], 'organization_slug') }, [data]) const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] = useState(false) const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( () => ({ clientSecret: setupIntent ? setupIntent.client_secret! : '', appearance: getStripeElementsAppearanceOptions(resolvedTheme), paymentMethodCreation: 'manual', }) as const, [setupIntent, resolvedTheme] ) const [formState, setFormState] = useState({ plan: 'FREE', name: '', kind: ORG_KIND_DEFAULT, size: ORG_SIZE_DEFAULT, spend_cap: true, }) const [searchParams] = useQueryStates({ returnTo: parseAsString.withDefault(''), auth_id: parseAsString.withDefault(''), }) const updateForm = (key: keyof FormState, value: unknown) => { setFormState((prev) => ({ ...prev, [key]: value })) } useEffect(() => { if (!router.isReady) return const { name, kind, plan, size, spend_cap } = router.query if (typeof name === 'string') updateForm('name', name) if (typeof kind === 'string') updateForm('kind', kind) if (typeof plan === 'string' && plans.includes(plan.toUpperCase() as (typeof plans)[number])) { const uppercasedPlan = plan.toUpperCase() as (typeof plans)[number] updateForm('plan', uppercasedPlan) onPlanSelected(uppercasedPlan) } if (typeof size === 'string') updateForm('size', size) if (typeof spend_cap === 'string') updateForm('spend_cap', spend_cap === 'true') }, [router.isReady]) useEffect(() => { if (!formState.name && organizations?.length === 0 && !user.isLoading) { const prefilledOrgName = user.profile?.username ? user.profile.username + `'s Org` : 'My Org' updateForm('name', prefilledOrgName) } }, [isSuccess]) const [newOrgLoading, setNewOrgLoading] = useState(false) const [paymentMethod, setPaymentMethod] = useState() const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false) const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) const { mutate: createOrganization } = useOrganizationCreateMutation({ onSuccess: async (org) => { if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) { setPaymentIntentSecret(org.pending_payment_intent_secret) } else { onOrganizationCreated(org as { slug: string }) } }, onError: (data) => { toast.error(data.message, { duration: 10_000 }) resetPaymentMethod() setNewOrgLoading(false) }, }) const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionCreateMutation({ onSuccess: (data) => { if (data && 'slug' in data) { onOrganizationCreated({ slug: data.slug }) } }, }) const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { // Reset payment intent secret to ensure another attempt works as expected setPaymentIntentSecret('') if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { await confirmPendingSubscriptionChange({ payment_intent_id: paymentIntentConfirmation.paymentIntent.id, name: formState.name, kind: formState.kind, size: formState.size, }) } else { // If the payment intent is not successful, we reset the payment method and show an error toast.error(`Could not confirm payment. Please try again or use a different card.`, { duration: 10_000, }) resetPaymentMethod() setNewOrgLoading(false) } } const onOrganizationCreated = (org: { slug: string }) => { const prefilledProjectName = user.profile?.username ? user.profile.username + `'s Project` : 'My Project' if (searchParams.returnTo && searchParams.auth_id) { router.push(`${searchParams.returnTo}?auth_id=${searchParams.auth_id}`, undefined, { shallow: false, }) } else { router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`) } } function validateOrgName(name: any) { const value = name ? name.trim() : '' return value.length >= 1 } const stripeOptionsConfirm = useMemo(() => { return { clientSecret: paymentIntentSecret, appearance: getStripeElementsAppearanceOptions(resolvedTheme), } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) async function createOrg( paymentMethodId?: string, customerData?: { address: CustomerAddress | null billing_name: string | null tax_id: CustomerTaxId | null } ) { const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan createOrganization({ name: formState.name, kind: formState.kind, tier: ('tier_' + dbTier.toLowerCase()) as | 'tier_payg' | 'tier_pro' | 'tier_free' | 'tier_team', ...(formState.kind == 'COMPANY' ? { size: formState.size } : {}), payment_method: paymentMethodId, billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name, address: dbTier === 'FREE' ? null : customerData?.address, tax_id: dbTier === 'FREE' ? undefined : customerData?.tax_id ?? undefined, }) } const paymentRef = useRef(null) const handleSubmit = async () => { setNewOrgLoading(true) if (formState.plan === 'FREE') { await createOrg() } else if (!paymentMethod) { const result = await paymentRef.current?.createPaymentMethod() if (result) { setPaymentMethod(result.paymentMethod) const customerData = { address: result.address, billing_name: result.customerName, tax_id: result.taxId, } createOrg(result.paymentMethod.id, customerData) } else { setNewOrgLoading(false) } } else { createOrg(paymentMethod.id) } } const resetPaymentMethod = () => { setPaymentMethod(undefined) return onPaymentMethodReset() } const onSubmitWithOrgCreation = async (event: any) => { event.preventDefault() const isOrgNameValid = validateOrgName(formState.name) if (!isOrgNameValid) { return toast.error('Organization name is empty') } const hasFreeOrgWithProjects = freeOrgs.some((it) => projectsByOrg[it.slug]?.length > 0) if (hasFreeOrgWithProjects && formState.plan !== 'FREE') { setIsOrgCreationConfirmationModalVisible(true) } else { await handleSubmit() } } return (

Create a new organization

} footer={

You can rename your organization later

} className="overflow-visible" >

This is your organization within Supabase.

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

Name
updateForm('name', e.target.value)} />
What's the name of your company or team?
Type
updateForm('kind', value)} > {Object.entries(ORG_KIND_TYPES).map(([k, v]) => ( {v} ))}
What would best describe your organization?
{formState.kind == 'COMPANY' && (
Company size
updateForm('size', value)} > {Object.entries(ORG_SIZE_TYPES).map(([k, v]) => ( {v} ))}
How many people are in your company?
)}
{ updateForm('plan', value) onPlanSelected(value) }} > {Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => ( {v} ))}
The Plan applies to your new organization.
{formState.plan === 'PRO' && ( <>
Spend Cap setShowSpendCapHelperModal(true)} />
updateForm('spend_cap', !formState.spend_cap)} /> {formState.spend_cap ? `Usage is limited to the plan's quota.` : `You pay for overages beyond the plan's quota.`}
setShowSpendCapHelperModal(false)} /> )} {setupIntent && formState.plan !== 'FREE' && ( )}
Confirm organization creation} confirmLabel="Create new organization" onCancel={() => setIsOrgCreationConfirmationModalVisible(false)} onConfirm={async () => { await handleSubmit() setIsOrgCreationConfirmationModalVisible(false) }} variant={'warning'} >

Supabase{' '} bills per organization . If you want to upgrade your existing projects, upgrade your existing organization instead.

    {freeOrgs .filter((it) => projectsByOrg[it.slug]?.length > 0) .map((org) => { const orgProjects = projectsByOrg[org.slug].map((it) => it.name) return (
  • {org.name}
    {orgProjects.length <= 2 ? ( {orgProjects.join('and ')} ) : (
    {orgProjects.slice(0, 2).join(', ')} and{' '} {orgProjects.length - 2} other{' '} {orgProjects.length === 3 ? 'project' : 'project'}
      {orgProjects.slice(2).map((project) => (
    • {project}
    • ))}
    )}
  • ) })}
{stripePromise && paymentIntentSecret && paymentMethod && ( paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} onError={(err) => { toast.error(err.message, { duration: 10_000 }) setNewOrgLoading(false) resetPaymentMethod() }} /> )}
) } export default NewOrgForm