AwsMarketplaceLinkExistingOrg.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { RadioGroupCard, RadioGroupCardItem } from '@ui/components/radio-group-card'
  3. import { cn } from '@ui/lib/utils'
  4. import { ChevronRight } from 'lucide-react'
  5. import Link from 'next/link'
  6. import { useRouter } from 'next/router'
  7. import { useMemo, useState } from 'react'
  8. import { SubmitHandler, useForm } from 'react-hook-form'
  9. import { toast } from 'sonner'
  10. import {
  11. Button,
  12. Collapsible,
  13. CollapsibleContent,
  14. CollapsibleTrigger,
  15. Form,
  16. FormField,
  17. Skeleton,
  18. } from 'ui'
  19. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  20. import { z } from 'zod'
  21. import { OrganizationCard } from '../OrganizationCard'
  22. import AwsMarketplaceAutoRenewalWarning from './AwsMarketplaceAutoRenewalWarning'
  23. import AwsMarketplaceOnboardingPlaceholder from './AwsMarketplaceOnboardingPlaceholder'
  24. import AwsMarketplaceOnboardingSuccessModal from './AwsMarketplaceOnboardingSuccessModal'
  25. import { useCloudMarketplaceOnboardingInfoQuery } from './cloud-marketplace-query'
  26. import NewAwsMarketplaceOrgModal from './NewAwsMarketplaceOrgModal'
  27. import {
  28. ScaffoldSection,
  29. ScaffoldSectionContent,
  30. ScaffoldSectionDetail,
  31. } from '@/components/layouts/Scaffold'
  32. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  33. import { useOrganizationLinkAwsMarketplaceMutation } from '@/data/organizations/organization-link-aws-marketplace-mutation'
  34. import { DOCS_URL } from '@/lib/constants'
  35. import type { Organization } from '@/types'
  36. interface AwsMarketplaceLinkExistingOrgProps {
  37. organizations?: Organization[] | undefined
  38. }
  39. const FormSchema = z.object({
  40. orgSlug: z.string(),
  41. })
  42. type LinkExistingOrgForm = z.infer<typeof FormSchema>
  43. export const AwsMarketplaceLinkExistingOrg = ({
  44. organizations,
  45. }: AwsMarketplaceLinkExistingOrgProps) => {
  46. const router = useRouter()
  47. const {
  48. query: { buyer_id: buyerId },
  49. } = router
  50. const { data: onboardingInfo, isPending: isLoadingOnboardingInfo } =
  51. useCloudMarketplaceOnboardingInfoQuery({
  52. buyerId: buyerId as string,
  53. })
  54. const form = useForm<LinkExistingOrgForm>({
  55. resolver: zodResolver(FormSchema as any),
  56. defaultValues: {
  57. orgSlug: undefined,
  58. },
  59. mode: 'onBlur',
  60. reValidateMode: 'onChange',
  61. })
  62. const isDirty = !!Object.keys(form.formState.dirtyFields).length
  63. // Sort organizations by name ascending
  64. const sortedOrganizations = useMemo(() => {
  65. return organizations?.slice().sort((a, b) => a.name.localeCompare(b.name))
  66. }, [organizations])
  67. const { orgsLinkable, orgsNotLinkable } = useMemo(() => {
  68. const orgQualifiesForLinking = (org: Organization) => {
  69. const validationResult = onboardingInfo?.organization_linking_eligibility.find(
  70. (result) => result.slug === org.slug
  71. )
  72. return validationResult?.is_eligible ?? false
  73. }
  74. const linkable: Organization[] = []
  75. const notLinkable: Organization[] = []
  76. sortedOrganizations?.forEach((org) => {
  77. if (orgQualifiesForLinking(org)) {
  78. linkable.push(org)
  79. } else {
  80. notLinkable.push(org)
  81. }
  82. })
  83. return { orgsLinkable: linkable, orgsNotLinkable: notLinkable }
  84. }, [sortedOrganizations, onboardingInfo?.organization_linking_eligibility])
  85. const [isNotLinkableOrgListOpen, setIsNotLinkableOrgListOpen] = useState(false)
  86. const [orgLinkedSuccessfully, setOrgLinkedSuccessfully] = useState(false)
  87. const [showOrgCreationDialog, setShowOrgCreationDialog] = useState(false)
  88. const [orgToRedirectTo, setOrgToRedirectTo] = useState('')
  89. const { mutate: linkOrganization, isPending: isLinkingOrganization } =
  90. useOrganizationLinkAwsMarketplaceMutation({
  91. onSuccess: (_) => {
  92. //TODO(thomas): send tracking event?
  93. setOrgLinkedSuccessfully(true)
  94. setOrgToRedirectTo(form.getValues('orgSlug'))
  95. },
  96. onError: (res) => {
  97. toast.error(res.message, {
  98. duration: 7_000,
  99. })
  100. },
  101. })
  102. const onSubmit: SubmitHandler<LinkExistingOrgForm> = async (values) => {
  103. linkOrganization({ slug: values.orgSlug, buyerId: buyerId as string })
  104. }
  105. return (
  106. <>
  107. {onboardingInfo &&
  108. !onboardingInfo.aws_contract_auto_renewal &&
  109. !onboardingInfo.aws_contract_is_private_offer && (
  110. <AwsMarketplaceAutoRenewalWarning
  111. awsContractEndDate={onboardingInfo.aws_contract_end_date}
  112. awsContractSettingsUrl={onboardingInfo.aws_contract_settings_url}
  113. />
  114. )}
  115. {isLoadingOnboardingInfo ? (
  116. <AwsMarketplaceOnboardingPlaceholder />
  117. ) : (
  118. <ScaffoldSection>
  119. <ScaffoldSectionDetail className="text-base">
  120. <>
  121. <p>
  122. You’ve subscribed to the Briven{' '}
  123. {onboardingInfo?.plan_name_selected_on_marketplace} Plan via the AWS Marketplace. As
  124. a final step, you need to link a Briven organization to that subscription. Select
  125. the organization you want to be managed and billed through AWS.
  126. </p>
  127. <p>
  128. You can read more on billing through AWS in our {''}
  129. <Link
  130. href={`${DOCS_URL}/guides/platform/aws-marketplace`}
  131. target="_blank"
  132. className="underline"
  133. >
  134. Billing Docs.
  135. </Link>
  136. </p>
  137. <p className="mt-10">
  138. <span className="font-bold text-foreground-light">Want to start fresh?</span> Create
  139. a new organization and it will be linked automatically.
  140. </p>
  141. <Button
  142. size="tiny"
  143. htmlType="submit"
  144. type="primary"
  145. onClick={async (e) => {
  146. e.preventDefault()
  147. setShowOrgCreationDialog(true)
  148. }}
  149. >
  150. Create organization
  151. </Button>
  152. </>
  153. </ScaffoldSectionDetail>
  154. <ScaffoldSectionContent className="lg:ml-10">
  155. <Form {...form}>
  156. <form className="flex flex-col">
  157. <FormField
  158. name="orgSlug"
  159. control={form.control}
  160. render={({ field }) => (
  161. <RadioGroupCard
  162. {...field}
  163. defaultValue={field.value}
  164. onValueChange={(value: string) => {
  165. form.setValue('orgSlug', value, {
  166. shouldDirty: true,
  167. shouldValidate: false,
  168. })
  169. }}
  170. >
  171. <FormItemLayout id={field.name}>
  172. <div className={'grid gap-4 grid-cols-1'}>
  173. {isLoadingOnboardingInfo ? (
  174. Array(3)
  175. .fill(0)
  176. .map((_, i) => (
  177. <Skeleton key={i} className="w-full h-[110px] rounded-md" />
  178. ))
  179. ) : (
  180. <>
  181. <p className="font-bold text-foreground-light">
  182. Organizations that can be linked
  183. </p>
  184. {orgsLinkable.length === 0 ? (
  185. <p className="text-sm text-foreground-light">
  186. None of your organizations can be linked to your AWS Marketplace
  187. subscription at the moment.
  188. </p>
  189. ) : (
  190. orgsLinkable.map((org) => (
  191. <RadioGroupCardItem
  192. id={org.slug}
  193. key={org.slug}
  194. showIndicator={false}
  195. value={org.slug}
  196. className={cn(
  197. 'relative text-sm text-left flex flex-col gap-0 p-0 [&_label]:w-full group w-full'
  198. )}
  199. label={
  200. <OrganizationCard
  201. key={org.id}
  202. isLink={false}
  203. organization={org}
  204. />
  205. }
  206. />
  207. ))
  208. )}
  209. </>
  210. )}
  211. </div>
  212. </FormItemLayout>
  213. </RadioGroupCard>
  214. )}
  215. />
  216. </form>
  217. </Form>
  218. {orgsNotLinkable.length > 0 && !isLoadingOnboardingInfo && (
  219. <Collapsible
  220. className="-space-y-px"
  221. open={isNotLinkableOrgListOpen || orgsLinkable.length === 0}
  222. onOpenChange={() => setIsNotLinkableOrgListOpen((prev) => !prev)}
  223. >
  224. <CollapsibleTrigger className="py-2 w-full flex items-center group justify-between">
  225. <p className="text-xs font-bold text-foreground-light">
  226. Organizations that can't be linked
  227. </p>
  228. <ChevronRight
  229. size={16}
  230. className="text-foreground-lighter transition-all group-data-open:rotate-90"
  231. strokeWidth={1}
  232. />
  233. </CollapsibleTrigger>
  234. <CollapsibleContent
  235. className={cn(
  236. 'flex flex-col gap-4 transition-all',
  237. 'data-closed:animate-collapsible-up data-open:animate-collapsible-down'
  238. )}
  239. >
  240. <p className="text-foreground-light text-xs">
  241. The following organizations can’t be linked to your AWS Marketplace subscription
  242. at the moment. This may be due to missing permissions, outstanding invoices, or
  243. an existing marketplace link. If you'd like to link one of these organizations,
  244. please review the organization settings. You need to be Owner or Administrator
  245. of the organization to link it.
  246. </p>
  247. <div className="text-sm text-left flex flex-col gap-4 p-0 [&_label]:w-full group] w-full opacity-60">
  248. {orgsNotLinkable.map((org) => (
  249. <OrganizationCard
  250. isLink={false}
  251. key={org.id}
  252. organization={org}
  253. className="cursor-not-allowed"
  254. />
  255. ))}
  256. </div>
  257. </CollapsibleContent>
  258. </Collapsible>
  259. )}
  260. <div className={cn('flex gap-3 justify-end')}>
  261. <ButtonTooltip
  262. size="medium"
  263. htmlType="submit"
  264. type="primary"
  265. onClick={async () => {
  266. await onSubmit(form.getValues())
  267. }}
  268. loading={isLinkingOrganization}
  269. disabled={!isDirty || isLinkingOrganization || isLoadingOnboardingInfo}
  270. tooltip={{
  271. content: {
  272. side: 'top',
  273. text: !isDirty ? 'No organization selected' : undefined,
  274. },
  275. }}
  276. >
  277. Link organization
  278. </ButtonTooltip>
  279. </div>
  280. </ScaffoldSectionContent>
  281. </ScaffoldSection>
  282. )}
  283. <AwsMarketplaceOnboardingSuccessModal
  284. visible={orgLinkedSuccessfully}
  285. onClose={() => {
  286. setOrgLinkedSuccessfully(false)
  287. router.push(`/org/${orgToRedirectTo}`)
  288. }}
  289. />
  290. <NewAwsMarketplaceOrgModal
  291. visible={showOrgCreationDialog}
  292. onClose={() => setShowOrgCreationDialog(false)}
  293. buyerId={buyerId as string}
  294. onSuccess={(newlyCreatedOrgSlug) => {
  295. setShowOrgCreationDialog(false)
  296. setOrgToRedirectTo(newlyCreatedOrgSlug)
  297. setOrgLinkedSuccessfully(true)
  298. }}
  299. />
  300. </>
  301. )
  302. }