RedeemCredits.tsx 6.8 KB

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