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