RedeemCredits.tsx206 lines · main
| 1 | import { FeatureFlagContext } from 'common' |
| 2 | import { useRouter } from 'next/router' |
| 3 | import { ReactNode, useContext, useEffect, useRef, useState } from 'react' |
| 4 | import { Button, Card, CardContent } from 'ui' |
| 5 | import { Admonition, ShimmeringLoader } from 'ui-patterns' |
| 6 | |
| 7 | import { OrganizationSelector } from '../Connect/OrganizationSelector' |
| 8 | import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption' |
| 9 | import { |
| 10 | InterstitialAccountRow, |
| 11 | InterstitialLayout, |
| 12 | BrivenLogo, |
| 13 | } from '@/components/layouts/InterstitialLayout' |
| 14 | import { useOrganizationsQuery } from '@/data/organizations/organizations-query' |
| 15 | import { useProfile } from '@/lib/profile' |
| 16 | import { EMPTY_ARR } from '@/lib/void' |
| 17 | |
| 18 | const RETURN_TO_SELECTED_ORG_PARAM = 'selected_org' |
| 19 | |
| 20 | const RedeemCreditsInterstitial = ({ |
| 21 | title, |
| 22 | description, |
| 23 | children, |
| 24 | }: { |
| 25 | title: ReactNode |
| 26 | description?: ReactNode |
| 27 | children: ReactNode |
| 28 | }) => ( |
| 29 | <InterstitialLayout logo={<BrivenLogo />} title={title} description={description}> |
| 30 | <div className="px-6 pb-6">{children}</div> |
| 31 | </InterstitialLayout> |
| 32 | ) |
| 33 | |
| 34 | export const RedeemCreditsScreen = () => { |
| 35 | const router = useRouter() |
| 36 | const { profile, isLoading: isLoadingProfile } = useProfile() |
| 37 | const { hasLoaded } = useContext(FeatureFlagContext) |
| 38 | |
| 39 | const [selectedOrgSlug, setSelectedOrgSlug] = useState<string | null>(null) |
| 40 | const [redemptionModalOrgSlug, setRedemptionModalOrgSlug] = useState<string | null>(null) |
| 41 | const appliedReturnSelectedOrgRef = useRef<string | null>(null) |
| 42 | |
| 43 | const { |
| 44 | data: organizationOptions = EMPTY_ARR, |
| 45 | error: organizationsError, |
| 46 | isLoading: isLoadingOrganizations, |
| 47 | isError: isOrganizationsError, |
| 48 | } = useOrganizationsQuery() |
| 49 | |
| 50 | const returnSelectedOrgSlug = |
| 51 | router.isReady && typeof router.query[RETURN_TO_SELECTED_ORG_PARAM] === 'string' |
| 52 | ? router.query[RETURN_TO_SELECTED_ORG_PARAM] |
| 53 | : null |
| 54 | |
| 55 | const displayName = profile?.primary_email ?? profile?.username |
| 56 | |
| 57 | const isLoading = isLoadingProfile || isLoadingOrganizations || !hasLoaded |
| 58 | |
| 59 | useEffect(() => { |
| 60 | if (!returnSelectedOrgSlug) return |
| 61 | if (appliedReturnSelectedOrgRef.current === returnSelectedOrgSlug) return |
| 62 | |
| 63 | const hasReturnedOrganization = (organizationOptions ?? []).some( |
| 64 | (organization) => organization.slug === returnSelectedOrgSlug |
| 65 | ) |
| 66 | |
| 67 | if (hasReturnedOrganization) { |
| 68 | setSelectedOrgSlug(returnSelectedOrgSlug) |
| 69 | appliedReturnSelectedOrgRef.current = returnSelectedOrgSlug |
| 70 | } |
| 71 | }, [organizationOptions, returnSelectedOrgSlug]) |
| 72 | |
| 73 | if (isLoading) { |
| 74 | return ( |
| 75 | <RedeemCreditsInterstitial |
| 76 | title={<ShimmeringLoader className="mx-auto h-7 w-32 max-w-full py-0" />} |
| 77 | description={<ShimmeringLoader className="mx-auto h-4 w-56 max-w-full py-0" />} |
| 78 | > |
| 79 | <ConnectLoadingCards /> |
| 80 | </RedeemCreditsInterstitial> |
| 81 | ) |
| 82 | } |
| 83 | |
| 84 | if (isOrganizationsError) { |
| 85 | return ( |
| 86 | <RedeemCreditsInterstitial |
| 87 | title="Unable to load credit redemption" |
| 88 | description="Please try again before redeeming this code" |
| 89 | > |
| 90 | <div className="flex flex-col gap-3"> |
| 91 | <Admonition |
| 92 | type="warning" |
| 93 | description={ |
| 94 | <> |
| 95 | We could not load your organizations. |
| 96 | {organizationsError && ( |
| 97 | <span className="mt-1 block text-foreground-lighter"> |
| 98 | Error: {organizationsError.message} |
| 99 | </span> |
| 100 | )} |
| 101 | </> |
| 102 | } |
| 103 | /> |
| 104 | </div> |
| 105 | </RedeemCreditsInterstitial> |
| 106 | ) |
| 107 | } |
| 108 | |
| 109 | const createOrganizationParams = { |
| 110 | returnTo: router.asPath || '/redeem', |
| 111 | returnToOrgParam: RETURN_TO_SELECTED_ORG_PARAM, |
| 112 | } |
| 113 | |
| 114 | const openRedemption = () => { |
| 115 | if (!selectedOrgSlug) return |
| 116 | setRedemptionModalOrgSlug(selectedOrgSlug) |
| 117 | } |
| 118 | |
| 119 | return ( |
| 120 | <> |
| 121 | <RedeemCreditsInterstitial |
| 122 | title="Redeem credits" |
| 123 | description="Choose an organization to redeem this code" |
| 124 | > |
| 125 | <div className="flex flex-col gap-5"> |
| 126 | <InterstitialAccountRow displayName={displayName} /> |
| 127 | |
| 128 | <OrganizationSelector |
| 129 | organizations={organizationOptions} |
| 130 | selectedSlug={selectedOrgSlug} |
| 131 | onSelect={setSelectedOrgSlug} |
| 132 | getOrganizationDescription={(organization) => `${organization.plan.name} Plan`} |
| 133 | createLabel={ |
| 134 | organizationOptions.length === 0 |
| 135 | ? 'Create your first organization' |
| 136 | : 'Create new organization' |
| 137 | } |
| 138 | createHrefParams={createOrganizationParams} |
| 139 | /> |
| 140 | |
| 141 | {organizationOptions.length === 0 && ( |
| 142 | <Admonition |
| 143 | type="warning" |
| 144 | description="Create an organization before redeeming this credit code." |
| 145 | /> |
| 146 | )} |
| 147 | |
| 148 | <div className="flex flex-col gap-2"> |
| 149 | <Button |
| 150 | block |
| 151 | type="primary" |
| 152 | disabled={!selectedOrgSlug || organizationOptions.length === 0} |
| 153 | onClick={openRedemption} |
| 154 | > |
| 155 | Redeem credits |
| 156 | </Button> |
| 157 | <p className="text-center text-xs text-foreground-lighter text-balance"> |
| 158 | Credits apply to one organization and are used toward future invoices before your |
| 159 | payment method is charged. |
| 160 | </p> |
| 161 | </div> |
| 162 | </div> |
| 163 | </RedeemCreditsInterstitial> |
| 164 | |
| 165 | {redemptionModalOrgSlug && ( |
| 166 | <CreditCodeRedemption |
| 167 | modalVisible |
| 168 | slug={redemptionModalOrgSlug} |
| 169 | onClose={() => setRedemptionModalOrgSlug(null)} |
| 170 | /> |
| 171 | )} |
| 172 | </> |
| 173 | ) |
| 174 | } |
| 175 | |
| 176 | const ConnectLoadingCards = () => ( |
| 177 | <div className="flex flex-col gap-5"> |
| 178 | <Card className="shadow-none"> |
| 179 | <CardContent className="flex items-center gap-3 border-none px-4 py-3"> |
| 180 | <ShimmeringLoader className="size-8 flex-shrink-0 rounded-full py-0" /> |
| 181 | <div className="min-w-0 flex-1 space-y-2"> |
| 182 | <ShimmeringLoader className="h-3 w-20 py-0" /> |
| 183 | <ShimmeringLoader className="h-4 w-40 max-w-full py-0" /> |
| 184 | </div> |
| 185 | </CardContent> |
| 186 | </Card> |
| 187 | <section className="space-y-2" aria-label="Organizations"> |
| 188 | <ShimmeringLoader className="h-3 w-24 py-0" /> |
| 189 | {Array.from({ length: 3 }).map((_, index) => ( |
| 190 | <Card key={index} className="shadow-none"> |
| 191 | <CardContent className="flex items-center gap-3 border-none px-4 py-3"> |
| 192 | <ShimmeringLoader className="size-9 flex-shrink-0 rounded-lg py-0" /> |
| 193 | <div className="min-w-0 flex-1 space-y-2"> |
| 194 | <ShimmeringLoader className="h-4 w-32 py-0" /> |
| 195 | <ShimmeringLoader className="h-3 w-20 py-0" /> |
| 196 | </div> |
| 197 | </CardContent> |
| 198 | </Card> |
| 199 | ))} |
| 200 | </section> |
| 201 | <div className="flex flex-col gap-2"> |
| 202 | <ShimmeringLoader className="h-10 w-full py-0" /> |
| 203 | <ShimmeringLoader className="h-10 w-full py-0" /> |
| 204 | </div> |
| 205 | </div> |
| 206 | ) |