NewOrgForm.tsx663 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { Elements } from '@stripe/react-stripe-js'
3import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
4import { loadStripe } from '@stripe/stripe-js'
5import { useDebounce } from '@uidotdev/usehooks'
6import { LOCAL_STORAGE_KEYS } from 'common'
7import { groupBy } from 'lodash'
8import { HelpCircle } from 'lucide-react'
9import { useTheme } from 'next-themes'
10import { useRouter } from 'next/router'
11import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
12import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
13import { SubmitHandler, useForm } from 'react-hook-form'
14import { toast } from 'sonner'
15import {
16 Button,
17 Form,
18 FormControl,
19 FormField,
20 Input,
21 Select,
22 SelectContent,
23 SelectItem,
24 SelectTrigger,
25 SelectValue,
26 Switch,
27} from 'ui'
28import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
29import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
30import { z } from 'zod'
31
32import { UpgradeExistingOrganizationCallout } from './UpgradeExistingOrganizationCallout'
33import { ChargeBreakdown } from '@/components/interfaces/Billing/ChargeBreakdown'
34import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
35import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation'
36import {
37 NewPaymentMethodElement,
38 type PaymentMethodElementRef,
39} from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
40import SpendCapModal from '@/components/interfaces/Billing/SpendCapModal'
41import { InlineLink } from '@/components/ui/InlineLink'
42import Panel from '@/components/ui/Panel'
43import { useOrganizationCreateMutation } from '@/data/organizations/organization-create-mutation'
44import { useOrganizationCreationPreview } from '@/data/organizations/organization-creation-preview'
45import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
46import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
47import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query'
48import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation'
49import { useConfirmPendingSubscriptionCreateMutation } from '@/data/subscriptions/org-subscription-confirm-pending-create'
50import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
51import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
52import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from '@/lib/constants'
53import { useProfile } from '@/lib/profile'
54
55const ORG_KIND_TYPES = {
56 PERSONAL: 'Personal',
57 EDUCATIONAL: 'Educational',
58 STARTUP: 'Startup',
59 AGENCY: 'Agency',
60 COMPANY: 'Company',
61 UNDISCLOSED: 'N/A',
62}
63const ORG_KIND_DEFAULT = 'PERSONAL'
64
65const ORG_SIZE_TYPES = {
66 '1': '1 - 10',
67 '10': '10 - 49',
68 '50': '50 - 99',
69 '100': '100 - 299',
70 '300': 'More than 300',
71}
72const ORG_SIZE_DEFAULT = '1'
73
74interface NewOrgFormProps {
75 onPaymentMethodReset: () => void
76 setupIntent?: SetupIntentResponse
77 onPlanSelected: (plan: string) => void
78}
79
80const plans = ['FREE', 'PRO', 'TEAM'] as const
81
82const formSchema = z.object({
83 plan: z
84 .string()
85 .transform((val) => val.toUpperCase())
86 .pipe(z.enum(plans)),
87 name: z.string().min(1, 'Organization name is required'),
88 kind: z
89 .string()
90 .transform((val) => val.toUpperCase())
91 .pipe(
92 z.enum(['PERSONAL', 'EDUCATIONAL', 'STARTUP', 'AGENCY', 'COMPANY', 'UNDISCLOSED'] as const)
93 ),
94 size: z.enum(['1', '10', '50', '100', '300'] as const),
95 spend_cap: z.boolean(),
96})
97
98type FormState = z.infer<typeof formSchema>
99
100const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
101
102const FORM_ID = 'new-org-form'
103
104/**
105 * No org selected yet, create a new one
106 * [Joshen] Need to refactor to use Form_Shadcn here
107 */
108export const NewOrgForm = ({
109 onPaymentMethodReset,
110 setupIntent,
111 onPlanSelected,
112}: NewOrgFormProps) => {
113 const router = useRouter()
114 const user = useProfile()
115 const { resolvedTheme } = useTheme()
116
117 const isBillingEnabled = useIsFeatureEnabled('billing:all')
118
119 const { data: organizations, isSuccess } = useOrganizationsQuery()
120 const { data } = useProjectsInfiniteQuery({})
121 const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages])
122
123 const [lastVisitedOrganization] = useLocalStorageQuery(
124 LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
125 ''
126 )
127
128 const freeOrgs = (organizations || []).filter((it) => it.plan.id === 'free')
129
130 // [Joshen] JFYI because we're now using a paginated endpoint, there's a chance that not all projects will be
131 // factored in here (page limit is 100 results). This data is mainly used for the `hasFreeOrgWithProjects` check
132 // in onSubmit below, which isn't a critical functionality imo so am okay for now. But ideally perhaps this data can
133 // be computed on the API and returned in /profile or something (since this data is on the account level)
134 const projectsByOrg = useMemo(() => {
135 return groupBy(projects, 'organization_slug')
136 }, [projects])
137
138 const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
139 () =>
140 ({
141 clientSecret: setupIntent ? setupIntent.client_secret! : '',
142 appearance: getStripeElementsAppearanceOptions(resolvedTheme),
143 paymentMethodCreation: 'manual',
144 }) as const,
145 [setupIntent, resolvedTheme]
146 )
147
148 const [searchParams] = useQueryStates({
149 returnTo: parseAsString.withDefault(''),
150 auth_id: parseAsString.withDefault(''),
151 token: parseAsString.withDefault(''),
152 })
153
154 const [defaultValues] = useQueryStates({
155 name: parseAsString.withDefault(''),
156 kind: parseAsString.withDefault(ORG_KIND_DEFAULT),
157 plan: parseAsString.withDefault('FREE'),
158 size: parseAsString.withDefault(ORG_SIZE_DEFAULT),
159 spend_cap: parseAsBoolean.withDefault(true),
160 })
161
162 const form = useForm<FormState>({
163 resolver: zodResolver(formSchema as any),
164 defaultValues: {
165 plan: defaultValues.plan.toUpperCase() as (typeof plans)[number],
166 name: defaultValues.name,
167 kind: defaultValues.kind as typeof ORG_KIND_DEFAULT,
168 size: defaultValues.size as keyof typeof ORG_SIZE_TYPES,
169 spend_cap: defaultValues.spend_cap,
170 },
171 })
172
173 useEffect(() => {
174 form.reset({
175 plan: defaultValues.plan.toUpperCase() as (typeof plans)[number],
176 name: defaultValues.name,
177 kind: defaultValues.kind as typeof ORG_KIND_DEFAULT,
178 size: defaultValues.size as keyof typeof ORG_SIZE_TYPES,
179 spend_cap: defaultValues.spend_cap,
180 })
181 }, [defaultValues, form])
182
183 useEffect(() => {
184 const currentName = form.getValues('name')
185 if (!currentName && isSuccess && organizations?.length === 0 && user.isSuccess) {
186 const prefilledOrgName = user.profile?.username ? user.profile.username + `'s Org` : 'My Org'
187 form.setValue('name', prefilledOrgName)
188 }
189 }, [isSuccess, form, organizations?.length, user.profile?.username, user.isSuccess])
190
191 const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
192 const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
193
194 const billingAddress = useDebounce(latestAddress, 1000)
195 const billingTaxId = useDebounce(latestTaxId, 1000)
196
197 const handleAddressChange = useCallback((address: CustomerAddress) => {
198 setLatestAddress({
199 ...address,
200 line2: address.line2 || undefined,
201 })
202 }, [])
203
204 const handleAddressIncomplete = useCallback(() => {
205 setLatestAddress(undefined)
206 }, [])
207
208 const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => {
209 setLatestTaxId(taxId)
210 }, [])
211
212 const selectedPlan = form.watch('plan')
213 const selectedSpendCap = form.watch('spend_cap')
214
215 useEffect(() => {
216 if (selectedPlan === 'FREE' || !setupIntent) {
217 setLatestAddress(undefined)
218 setLatestTaxId(null)
219 }
220 }, [selectedPlan, setupIntent])
221
222 const previewTier = useMemo(() => {
223 if (selectedPlan === 'FREE') return undefined
224 const dbTier = selectedPlan === 'PRO' && !selectedSpendCap ? 'PAYG' : selectedPlan
225 return ('tier_' + dbTier.toLowerCase()) as 'tier_pro' | 'tier_payg' | 'tier_team'
226 }, [selectedPlan, selectedSpendCap])
227
228 const {
229 data: creationPreview,
230 isFetching: creationPreviewIsFetching,
231 isSuccess: creationPreviewInitialized,
232 } = useOrganizationCreationPreview(
233 {
234 tier: previewTier,
235 address: billingAddress,
236 taxId: billingTaxId ?? undefined,
237 },
238 { enabled: !!previewTier && !!billingAddress }
239 )
240
241 const [newOrgLoading, setNewOrgLoading] = useState(false)
242 const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>()
243
244 const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
245 const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
246 const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
247
248 const hasFreeOrgWithProjects = useMemo(
249 () => freeOrgs.some((it) => projectsByOrg[it.slug]?.length > 0),
250 [freeOrgs, projectsByOrg]
251 )
252
253 const { mutate: createOrganization } = useOrganizationCreateMutation({
254 onSuccess: async (org) => {
255 if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) {
256 setPaymentIntentSecret(org.pending_payment_intent_secret)
257 } else {
258 onOrganizationCreated(org as { slug: string })
259 }
260 },
261 onError: (data) => {
262 toast.error(data.message, { duration: 10_000 })
263 setNewOrgLoading(false)
264 },
265 })
266
267 const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionCreateMutation({
268 onSuccess: (data) => {
269 if (data && 'slug' in data) {
270 onOrganizationCreated({ slug: data.slug })
271 }
272 },
273 })
274
275 const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => {
276 // Reset payment intent secret to ensure another attempt works as expected
277 setPaymentIntentSecret('')
278
279 if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') {
280 await confirmPendingSubscriptionChange({
281 payment_intent_id: paymentIntentConfirmation.paymentIntent.id,
282 name: form.getValues('name'),
283 kind: form.getValues('kind'),
284 size: form.getValues('size'),
285 })
286 } else {
287 // If the payment intent is not successful, we reset the payment method and show an error
288 toast.error(`Could not confirm payment. Please try again or use a different card.`, {
289 duration: 10_000,
290 })
291 resetPaymentMethod()
292 setNewOrgLoading(false)
293 }
294 }
295
296 const onOrganizationCreated = (org: { slug: string }) => {
297 const prefilledProjectName = user.profile?.username
298 ? user.profile.username + `'s Project`
299 : 'My Project'
300
301 if (searchParams.returnTo) {
302 const url = new URL(searchParams.returnTo, window.location.origin)
303 if (searchParams.auth_id) {
304 url.searchParams.set('auth_id', searchParams.auth_id)
305 }
306 if (searchParams.token) {
307 url.searchParams.set('token', searchParams.token)
308 }
309
310 router.push(url.toString(), undefined, { shallow: false })
311 } else {
312 router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
313 }
314 }
315
316 const stripeOptionsConfirm = useMemo(() => {
317 return {
318 clientSecret: paymentIntentSecret,
319 appearance: getStripeElementsAppearanceOptions(resolvedTheme),
320 } as StripeElementsOptions
321 }, [paymentIntentSecret, resolvedTheme])
322
323 async function createOrg(
324 formValues: z.infer<typeof formSchema>,
325 paymentMethodId?: string,
326 customerData?: {
327 address: CustomerAddress | null
328 billing_name: string | null
329 tax_id: CustomerTaxId | null
330 }
331 ) {
332 const dbTier = formValues.plan === 'PRO' && !formValues.spend_cap ? 'PAYG' : formValues.plan
333
334 createOrganization({
335 name: formValues.name,
336 kind: formValues.kind,
337 tier: ('tier_' + dbTier.toLowerCase()) as
338 | 'tier_payg'
339 | 'tier_pro'
340 | 'tier_free'
341 | 'tier_team',
342 ...(formValues.kind == 'COMPANY' ? { size: formValues.size } : {}),
343 payment_method: paymentMethodId,
344 billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name,
345 address: dbTier === 'FREE' ? null : customerData?.address,
346 tax_id: dbTier === 'FREE' ? undefined : (customerData?.tax_id ?? undefined),
347 })
348 }
349
350 const paymentRef = useRef<PaymentMethodElementRef | null>(null)
351
352 const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (formValues) => {
353 setNewOrgLoading(true)
354
355 if (formValues.plan === 'FREE') {
356 await createOrg(formValues)
357 return
358 }
359
360 const result = await paymentRef.current?.createPaymentMethod()
361 if (!result) {
362 setNewOrgLoading(false)
363 return
364 }
365
366 setPaymentMethod(result.paymentMethod)
367 createOrg(formValues, result.paymentMethod.id, {
368 address: result.address,
369 billing_name: result.customerName,
370 tax_id: result.taxId,
371 })
372 }
373
374 const resetPaymentMethod = () => {
375 setPaymentMethod(undefined)
376 return onPaymentMethodReset()
377 }
378
379 return (
380 <Form {...form}>
381 <form onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
382 <Panel
383 title={
384 <div key="panel-title">
385 <h3>Create a new organization</h3>
386 <p className="text-sm text-foreground-lighter text-balance">
387 Organizations are a way to group your projects. Each organization can be configured
388 with different team members and billing settings.
389 </p>
390 </div>
391 }
392 footer={
393 <div key="panel-footer" className="flex w-full items-center justify-between">
394 <Button
395 type="default"
396 disabled={newOrgLoading || paymentConfirmationLoading}
397 onClick={() => {
398 if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
399 else router.push('/organizations')
400 }}
401 >
402 Cancel
403 </Button>
404
405 <Button
406 form={FORM_ID}
407 htmlType="submit"
408 type="primary"
409 loading={newOrgLoading}
410 disabled={newOrgLoading || creationPreviewIsFetching}
411 >
412 Create organization
413 </Button>
414 </div>
415 }
416 // Allow address dropdown in Stripe Elements to overflow the panel
417 noHideOverflow
418 // Prevent resulting rounded corners in footer being clipped by squared corners of bg
419 titleClasses="rounded-t-md"
420 footerClasses="rounded-b-md"
421 >
422 <div className="divide-y divide-border-muted">
423 <Panel.Content>
424 <FormField
425 control={form.control}
426 name="name"
427 render={({ field }) => (
428 <FormItemLayout
429 label="Name"
430 layout="horizontal"
431 description="What's the name of your company or team? You can change this later."
432 >
433 <FormControl>
434 <Input
435 autoFocus
436 type="text"
437 placeholder="Organization name"
438 data-1p-ignore
439 data-lpignore="true"
440 data-form-type="other"
441 data-bwignore
442 {...field}
443 />
444 </FormControl>
445 </FormItemLayout>
446 )}
447 />
448 </Panel.Content>
449 <Panel.Content>
450 <FormField
451 control={form.control}
452 name="kind"
453 render={({ field }) => (
454 <FormItemLayout
455 label="Type"
456 layout="horizontal"
457 description="What best describes your organization?"
458 >
459 <FormControl>
460 <Select value={field.value} onValueChange={field.onChange}>
461 <SelectTrigger className="w-full">
462 <SelectValue />
463 </SelectTrigger>
464
465 <SelectContent>
466 {Object.entries(ORG_KIND_TYPES).map(([k, v]) => (
467 <SelectItem key={k} value={k}>
468 {v}
469 </SelectItem>
470 ))}
471 </SelectContent>
472 </Select>
473 </FormControl>
474 </FormItemLayout>
475 )}
476 />
477 </Panel.Content>
478
479 {form.watch('kind') == 'COMPANY' && (
480 <Panel.Content>
481 <FormField
482 control={form.control}
483 name="size"
484 render={({ field }) => (
485 <FormItemLayout
486 label="Company size"
487 layout="horizontal"
488 description="How many people are in your company?"
489 >
490 <FormControl>
491 <Select value={field.value} onValueChange={field.onChange}>
492 <SelectTrigger className="w-full">
493 <SelectValue />
494 </SelectTrigger>
495
496 <SelectContent>
497 {Object.entries(ORG_SIZE_TYPES).map(([k, v]) => (
498 <SelectItem key={k} value={k}>
499 {v}
500 </SelectItem>
501 ))}
502 </SelectContent>
503 </Select>
504 </FormControl>
505 </FormItemLayout>
506 )}
507 />
508 </Panel.Content>
509 )}
510
511 {isBillingEnabled && (
512 <Panel.Content>
513 <FormField
514 control={form.control}
515 name="plan"
516 render={({ field }) => (
517 <FormItemLayout
518 label="Plan"
519 layout="horizontal"
520 description={
521 <>
522 Which plan fits your organization's needs best?{' '}
523 <InlineLink href="https://supabase.com/pricing">Learn more</InlineLink>.
524 </>
525 }
526 >
527 <FormControl>
528 <Select
529 value={field.value}
530 onValueChange={(value) => {
531 field.onChange(value)
532 onPlanSelected(value)
533 }}
534 >
535 <SelectTrigger className="w-full">
536 <SelectValue />
537 </SelectTrigger>
538
539 <SelectContent>
540 {Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => (
541 <SelectItem key={k} value={k} translate="no">
542 {v}
543 </SelectItem>
544 ))}
545 </SelectContent>
546 </Select>
547 </FormControl>
548 </FormItemLayout>
549 )}
550 />
551 </Panel.Content>
552 )}
553
554 {form.watch('plan') === 'PRO' && (
555 <>
556 <Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
557 <FormField
558 control={form.control}
559 name="spend_cap"
560 render={({ field }) => (
561 <FormItemLayout
562 label={
563 <div className="flex space-x-2 text-sm items-center">
564 <span>Spend Cap</span>
565 <HelpCircle
566 size={16}
567 strokeWidth={1.5}
568 className="transition opacity-50 cursor-pointer hover:opacity-100"
569 onClick={() => setShowSpendCapHelperModal(true)}
570 />
571 </div>
572 }
573 layout="horizontal"
574 description={
575 field.value
576 ? `Usage is limited to the plan's quota.`
577 : `You pay for overages beyond the plan's quota.`
578 }
579 >
580 <FormControl>
581 <Switch checked={field.value} onCheckedChange={field.onChange} />
582 </FormControl>
583 </FormItemLayout>
584 )}
585 />
586 </Panel.Content>
587
588 <SpendCapModal
589 visible={showSpendCapHelperModal}
590 onHide={() => setShowSpendCapHelperModal(false)}
591 />
592 </>
593 )}
594
595 {setupIntent && form.watch('plan') !== 'FREE' && (
596 <Panel.Content className="pt-5">
597 <Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
598 <NewPaymentMethodElement
599 ref={paymentRef}
600 email={user.profile?.primary_email}
601 readOnly={newOrgLoading || paymentConfirmationLoading}
602 onAddressChange={handleAddressChange}
603 onAddressIncomplete={handleAddressIncomplete}
604 onTaxIdChange={handleTaxIdChange}
605 />
606 </Elements>
607
608 {!!billingAddress && !creationPreviewInitialized && (
609 <div className="space-y-2 mt-4">
610 <ShimmeringLoader />
611 <ShimmeringLoader className="w-3/4" />
612 <ShimmeringLoader className="w-1/2" />
613 </div>
614 )}
615
616 {creationPreviewInitialized && !!billingAddress && (
617 <div className="mt-4">
618 <ChargeBreakdown
619 subtotal={creationPreview.plan_price}
620 subtotalLabel="Plan price"
621 total={creationPreview.total}
622 tax={
623 creationPreview.tax
624 ? {
625 amount: creationPreview.tax.tax_amount,
626 percentage: creationPreview.tax.tax_rate_percentage,
627 }
628 : undefined
629 }
630 taxStatus={creationPreview.tax_status}
631 isFetching={creationPreviewIsFetching}
632 />
633 </div>
634 )}
635 </Panel.Content>
636 )}
637
638 {hasFreeOrgWithProjects && form.getValues('plan') !== 'FREE' && (
639 <UpgradeExistingOrganizationCallout />
640 )}
641 </div>
642 </Panel>
643
644 {stripePromise && paymentIntentSecret && paymentMethod && (
645 <Elements stripe={stripePromise} options={stripeOptionsConfirm}>
646 <PaymentConfirmation
647 paymentIntentSecret={paymentIntentSecret}
648 onPaymentIntentConfirm={(paymentIntentConfirmation) =>
649 paymentIntentConfirmed(paymentIntentConfirmation)
650 }
651 onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
652 onError={(err) => {
653 toast.error(err.message, { duration: 10_000 })
654 setNewOrgLoading(false)
655 resetPaymentMethod()
656 }}
657 />
658 </Elements>
659 )}
660 </form>
661 </Form>
662 )
663}