AwsMarketplaceLinkExistingOrg.tsx325 lines · main
| 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 | |
| 22 | import { OrganizationCard } from '../OrganizationCard' |
| 23 | import AwsMarketplaceAutoRenewalWarning from './AwsMarketplaceAutoRenewalWarning' |
| 24 | import AwsMarketplaceOnboardingPlaceholder from './AwsMarketplaceOnboardingPlaceholder' |
| 25 | import AwsMarketplaceOnboardingSuccessModal from './AwsMarketplaceOnboardingSuccessModal' |
| 26 | import { useCloudMarketplaceOnboardingInfoQuery } from './cloud-marketplace-query' |
| 27 | import NewAwsMarketplaceOrgModal from './NewAwsMarketplaceOrgModal' |
| 28 | import { |
| 29 | ScaffoldSection, |
| 30 | ScaffoldSectionContent, |
| 31 | ScaffoldSectionDetail, |
| 32 | } from '@/components/layouts/Scaffold' |
| 33 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 34 | import { useOrganizationLinkAwsMarketplaceMutation } from '@/data/organizations/organization-link-aws-marketplace-mutation' |
| 35 | import { DOCS_URL } from '@/lib/constants' |
| 36 | import type { Organization } from '@/types' |
| 37 | |
| 38 | interface AwsMarketplaceLinkExistingOrgProps { |
| 39 | organizations?: Organization[] | undefined |
| 40 | } |
| 41 | |
| 42 | const FormSchema = z.object({ |
| 43 | orgSlug: z.string(), |
| 44 | }) |
| 45 | |
| 46 | type LinkExistingOrgForm = z.infer<typeof FormSchema> |
| 47 | |
| 48 | export 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 | } |