| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { Elements } from '@stripe/react-stripe-js'
- import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
- import { loadStripe } from '@stripe/stripe-js'
- import { useDebounce } from '@uidotdev/usehooks'
- import { LOCAL_STORAGE_KEYS } from 'common'
- import { groupBy } from 'lodash'
- import { HelpCircle } from 'lucide-react'
- import { useTheme } from 'next-themes'
- import { useRouter } from 'next/router'
- import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
- import { SubmitHandler, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Form,
- FormControl,
- FormField,
- Input,
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- Switch,
- } from 'ui'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
- import { z } from 'zod'
- import { UpgradeExistingOrganizationCallout } from './UpgradeExistingOrganizationCallout'
- import { ChargeBreakdown } from '@/components/interfaces/Billing/ChargeBreakdown'
- import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
- import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation'
- import {
- NewPaymentMethodElement,
- type PaymentMethodElementRef,
- } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
- import SpendCapModal from '@/components/interfaces/Billing/SpendCapModal'
- import { InlineLink } from '@/components/ui/InlineLink'
- import Panel from '@/components/ui/Panel'
- import { useOrganizationCreateMutation } from '@/data/organizations/organization-create-mutation'
- import { useOrganizationCreationPreview } from '@/data/organizations/organization-creation-preview'
- import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
- import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
- import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query'
- import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation'
- import { useConfirmPendingSubscriptionCreateMutation } from '@/data/subscriptions/org-subscription-confirm-pending-create'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
- import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from '@/lib/constants'
- import { useProfile } from '@/lib/profile'
- 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, 'Organization name is required'),
- 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<typeof formSchema>
- const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
- const FORM_ID = 'new-org-form'
- /**
- * No org selected yet, create a new one
- * [Joshen] Need to refactor to use Form_Shadcn here
- */
- export const NewOrgForm = ({
- onPaymentMethodReset,
- setupIntent,
- onPlanSelected,
- }: NewOrgFormProps) => {
- const router = useRouter()
- const user = useProfile()
- const { resolvedTheme } = useTheme()
- const isBillingEnabled = useIsFeatureEnabled('billing:all')
- const { data: organizations, isSuccess } = useOrganizationsQuery()
- const { data } = useProjectsInfiniteQuery({})
- const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages])
- const [lastVisitedOrganization] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
- ''
- )
- const freeOrgs = (organizations || []).filter((it) => it.plan.id === 'free')
- // [Joshen] JFYI because we're now using a paginated endpoint, there's a chance that not all projects will be
- // factored in here (page limit is 100 results). This data is mainly used for the `hasFreeOrgWithProjects` check
- // in onSubmit below, which isn't a critical functionality imo so am okay for now. But ideally perhaps this data can
- // be computed on the API and returned in /profile or something (since this data is on the account level)
- const projectsByOrg = useMemo(() => {
- return groupBy(projects, 'organization_slug')
- }, [projects])
- const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
- () =>
- ({
- clientSecret: setupIntent ? setupIntent.client_secret! : '',
- appearance: getStripeElementsAppearanceOptions(resolvedTheme),
- paymentMethodCreation: 'manual',
- }) as const,
- [setupIntent, resolvedTheme]
- )
- const [searchParams] = useQueryStates({
- returnTo: parseAsString.withDefault(''),
- auth_id: parseAsString.withDefault(''),
- token: parseAsString.withDefault(''),
- })
- const [defaultValues] = useQueryStates({
- name: parseAsString.withDefault(''),
- kind: parseAsString.withDefault(ORG_KIND_DEFAULT),
- plan: parseAsString.withDefault('FREE'),
- size: parseAsString.withDefault(ORG_SIZE_DEFAULT),
- spend_cap: parseAsBoolean.withDefault(true),
- })
- const form = useForm<FormState>({
- resolver: zodResolver(formSchema as any),
- defaultValues: {
- plan: defaultValues.plan.toUpperCase() as (typeof plans)[number],
- name: defaultValues.name,
- kind: defaultValues.kind as typeof ORG_KIND_DEFAULT,
- size: defaultValues.size as keyof typeof ORG_SIZE_TYPES,
- spend_cap: defaultValues.spend_cap,
- },
- })
- useEffect(() => {
- form.reset({
- plan: defaultValues.plan.toUpperCase() as (typeof plans)[number],
- name: defaultValues.name,
- kind: defaultValues.kind as typeof ORG_KIND_DEFAULT,
- size: defaultValues.size as keyof typeof ORG_SIZE_TYPES,
- spend_cap: defaultValues.spend_cap,
- })
- }, [defaultValues, form])
- useEffect(() => {
- const currentName = form.getValues('name')
- if (!currentName && isSuccess && organizations?.length === 0 && user.isSuccess) {
- const prefilledOrgName = user.profile?.username ? user.profile.username + `'s Org` : 'My Org'
- form.setValue('name', prefilledOrgName)
- }
- }, [isSuccess, form, organizations?.length, user.profile?.username, user.isSuccess])
- const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
- const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
- const billingAddress = useDebounce(latestAddress, 1000)
- const billingTaxId = useDebounce(latestTaxId, 1000)
- const handleAddressChange = useCallback((address: CustomerAddress) => {
- setLatestAddress({
- ...address,
- line2: address.line2 || undefined,
- })
- }, [])
- const handleAddressIncomplete = useCallback(() => {
- setLatestAddress(undefined)
- }, [])
- const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => {
- setLatestTaxId(taxId)
- }, [])
- const selectedPlan = form.watch('plan')
- const selectedSpendCap = form.watch('spend_cap')
- useEffect(() => {
- if (selectedPlan === 'FREE' || !setupIntent) {
- setLatestAddress(undefined)
- setLatestTaxId(null)
- }
- }, [selectedPlan, setupIntent])
- const previewTier = useMemo(() => {
- if (selectedPlan === 'FREE') return undefined
- const dbTier = selectedPlan === 'PRO' && !selectedSpendCap ? 'PAYG' : selectedPlan
- return ('tier_' + dbTier.toLowerCase()) as 'tier_pro' | 'tier_payg' | 'tier_team'
- }, [selectedPlan, selectedSpendCap])
- const {
- data: creationPreview,
- isFetching: creationPreviewIsFetching,
- isSuccess: creationPreviewInitialized,
- } = useOrganizationCreationPreview(
- {
- tier: previewTier,
- address: billingAddress,
- taxId: billingTaxId ?? undefined,
- },
- { enabled: !!previewTier && !!billingAddress }
- )
- const [newOrgLoading, setNewOrgLoading] = useState(false)
- const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>()
- const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
- const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
- const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
- const hasFreeOrgWithProjects = useMemo(
- () => freeOrgs.some((it) => projectsByOrg[it.slug]?.length > 0),
- [freeOrgs, projectsByOrg]
- )
- 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 })
- 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: form.getValues('name'),
- kind: form.getValues('kind'),
- size: form.getValues('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) {
- const url = new URL(searchParams.returnTo, window.location.origin)
- if (searchParams.auth_id) {
- url.searchParams.set('auth_id', searchParams.auth_id)
- }
- if (searchParams.token) {
- url.searchParams.set('token', searchParams.token)
- }
- router.push(url.toString(), undefined, { shallow: false })
- } else {
- router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
- }
- }
- const stripeOptionsConfirm = useMemo(() => {
- return {
- clientSecret: paymentIntentSecret,
- appearance: getStripeElementsAppearanceOptions(resolvedTheme),
- } as StripeElementsOptions
- }, [paymentIntentSecret, resolvedTheme])
- async function createOrg(
- formValues: z.infer<typeof formSchema>,
- paymentMethodId?: string,
- customerData?: {
- address: CustomerAddress | null
- billing_name: string | null
- tax_id: CustomerTaxId | null
- }
- ) {
- const dbTier = formValues.plan === 'PRO' && !formValues.spend_cap ? 'PAYG' : formValues.plan
- createOrganization({
- name: formValues.name,
- kind: formValues.kind,
- tier: ('tier_' + dbTier.toLowerCase()) as
- | 'tier_payg'
- | 'tier_pro'
- | 'tier_free'
- | 'tier_team',
- ...(formValues.kind == 'COMPANY' ? { size: formValues.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<PaymentMethodElementRef | null>(null)
- const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (formValues) => {
- setNewOrgLoading(true)
- if (formValues.plan === 'FREE') {
- await createOrg(formValues)
- return
- }
- const result = await paymentRef.current?.createPaymentMethod()
- if (!result) {
- setNewOrgLoading(false)
- return
- }
- setPaymentMethod(result.paymentMethod)
- createOrg(formValues, result.paymentMethod.id, {
- address: result.address,
- billing_name: result.customerName,
- tax_id: result.taxId,
- })
- }
- const resetPaymentMethod = () => {
- setPaymentMethod(undefined)
- return onPaymentMethodReset()
- }
- return (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
- <Panel
- title={
- <div key="panel-title">
- <h3>Create a new organization</h3>
- <p className="text-sm text-foreground-lighter text-balance">
- Organizations are a way to group your projects. Each organization can be configured
- with different team members and billing settings.
- </p>
- </div>
- }
- footer={
- <div key="panel-footer" className="flex w-full items-center justify-between">
- <Button
- type="default"
- disabled={newOrgLoading || paymentConfirmationLoading}
- onClick={() => {
- if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
- else router.push('/organizations')
- }}
- >
- Cancel
- </Button>
- <Button
- form={FORM_ID}
- htmlType="submit"
- type="primary"
- loading={newOrgLoading}
- disabled={newOrgLoading || creationPreviewIsFetching}
- >
- Create organization
- </Button>
- </div>
- }
- // Allow address dropdown in Stripe Elements to overflow the panel
- noHideOverflow
- // Prevent resulting rounded corners in footer being clipped by squared corners of bg
- titleClasses="rounded-t-md"
- footerClasses="rounded-b-md"
- >
- <div className="divide-y divide-border-muted">
- <Panel.Content>
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItemLayout
- label="Name"
- layout="horizontal"
- description="What's the name of your company or team? You can change this later."
- >
- <FormControl>
- <Input
- autoFocus
- type="text"
- placeholder="Organization name"
- data-1p-ignore
- data-lpignore="true"
- data-form-type="other"
- data-bwignore
- {...field}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </Panel.Content>
- <Panel.Content>
- <FormField
- control={form.control}
- name="kind"
- render={({ field }) => (
- <FormItemLayout
- label="Type"
- layout="horizontal"
- description="What best describes your organization?"
- >
- <FormControl>
- <Select value={field.value} onValueChange={field.onChange}>
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(ORG_KIND_TYPES).map(([k, v]) => (
- <SelectItem key={k} value={k}>
- {v}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormControl>
- </FormItemLayout>
- )}
- />
- </Panel.Content>
- {form.watch('kind') == 'COMPANY' && (
- <Panel.Content>
- <FormField
- control={form.control}
- name="size"
- render={({ field }) => (
- <FormItemLayout
- label="Company size"
- layout="horizontal"
- description="How many people are in your company?"
- >
- <FormControl>
- <Select value={field.value} onValueChange={field.onChange}>
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(ORG_SIZE_TYPES).map(([k, v]) => (
- <SelectItem key={k} value={k}>
- {v}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormControl>
- </FormItemLayout>
- )}
- />
- </Panel.Content>
- )}
- {isBillingEnabled && (
- <Panel.Content>
- <FormField
- control={form.control}
- name="plan"
- render={({ field }) => (
- <FormItemLayout
- label="Plan"
- layout="horizontal"
- description={
- <>
- Which plan fits your organization's needs best?{' '}
- <InlineLink href="https://supabase.com/pricing">Learn more</InlineLink>.
- </>
- }
- >
- <FormControl>
- <Select
- value={field.value}
- onValueChange={(value) => {
- field.onChange(value)
- onPlanSelected(value)
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => (
- <SelectItem key={k} value={k} translate="no">
- {v}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormControl>
- </FormItemLayout>
- )}
- />
- </Panel.Content>
- )}
- {form.watch('plan') === 'PRO' && (
- <>
- <Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
- <FormField
- control={form.control}
- name="spend_cap"
- render={({ field }) => (
- <FormItemLayout
- label={
- <div className="flex space-x-2 text-sm items-center">
- <span>Spend Cap</span>
- <HelpCircle
- size={16}
- strokeWidth={1.5}
- className="transition opacity-50 cursor-pointer hover:opacity-100"
- onClick={() => setShowSpendCapHelperModal(true)}
- />
- </div>
- }
- layout="horizontal"
- description={
- field.value
- ? `Usage is limited to the plan's quota.`
- : `You pay for overages beyond the plan's quota.`
- }
- >
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </Panel.Content>
- <SpendCapModal
- visible={showSpendCapHelperModal}
- onHide={() => setShowSpendCapHelperModal(false)}
- />
- </>
- )}
- {setupIntent && form.watch('plan') !== 'FREE' && (
- <Panel.Content className="pt-5">
- <Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
- <NewPaymentMethodElement
- ref={paymentRef}
- email={user.profile?.primary_email}
- readOnly={newOrgLoading || paymentConfirmationLoading}
- onAddressChange={handleAddressChange}
- onAddressIncomplete={handleAddressIncomplete}
- onTaxIdChange={handleTaxIdChange}
- />
- </Elements>
- {!!billingAddress && !creationPreviewInitialized && (
- <div className="space-y-2 mt-4">
- <ShimmeringLoader />
- <ShimmeringLoader className="w-3/4" />
- <ShimmeringLoader className="w-1/2" />
- </div>
- )}
- {creationPreviewInitialized && !!billingAddress && (
- <div className="mt-4">
- <ChargeBreakdown
- subtotal={creationPreview.plan_price}
- subtotalLabel="Plan price"
- total={creationPreview.total}
- tax={
- creationPreview.tax
- ? {
- amount: creationPreview.tax.tax_amount,
- percentage: creationPreview.tax.tax_rate_percentage,
- }
- : undefined
- }
- taxStatus={creationPreview.tax_status}
- isFetching={creationPreviewIsFetching}
- />
- </div>
- )}
- </Panel.Content>
- )}
- {hasFreeOrgWithProjects && form.getValues('plan') !== 'FREE' && (
- <UpgradeExistingOrganizationCallout />
- )}
- </div>
- </Panel>
- {stripePromise && paymentIntentSecret && paymentMethod && (
- <Elements stripe={stripePromise} options={stripeOptionsConfirm}>
- <PaymentConfirmation
- paymentIntentSecret={paymentIntentSecret}
- onPaymentIntentConfirm={(paymentIntentConfirmation) =>
- paymentIntentConfirmed(paymentIntentConfirmation)
- }
- onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
- onError={(err) => {
- toast.error(err.message, { duration: 10_000 })
- setNewOrgLoading(false)
- resetPaymentMethod()
- }}
- />
- </Elements>
- )}
- </form>
- </Form>
- )
- }
|