NewOrgForm.tsx 24 KB

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