CreditCodeRedemption.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import HCaptcha from '@hcaptcha/react-hcaptcha'
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { PermissionAction } from '@supabase/shared-types/out/constants'
  4. import { Calendar, PartyPopper } from 'lucide-react'
  5. import Link from 'next/link'
  6. import { useRouter } from 'next/router'
  7. import { useEffect, useRef, useState } from 'react'
  8. import { SubmitHandler, useForm } from 'react-hook-form'
  9. import {
  10. Button,
  11. Dialog,
  12. DialogContent,
  13. DialogDescription,
  14. DialogFooter,
  15. DialogHeader,
  16. DialogSection,
  17. DialogSectionSeparator,
  18. DialogTitle,
  19. DialogTrigger,
  20. Form,
  21. FormField,
  22. Input,
  23. Separator,
  24. } from 'ui'
  25. import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns'
  26. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  27. import { z } from 'zod'
  28. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  29. import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
  30. import { useOrganizationCreditCodeRedemptionMutation } from '@/data/organizations/organization-credit-code-redemption-mutation'
  31. import { useOrganizationQuery } from '@/data/organizations/organization-query'
  32. import { useOrgBalanceQuery } from '@/data/subscriptions/org-balance-query'
  33. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  34. import { useLatest } from '@/hooks/misc/useLatest'
  35. const FORM_ID = 'credit-code-redemption'
  36. const FormSchema = z.object({
  37. code: z.string().min(1, 'Code is required'),
  38. })
  39. type CreditCodeRedemptionForm = z.infer<typeof FormSchema>
  40. export const CreditCodeRedemption = ({
  41. slug,
  42. modalVisible = false,
  43. onClose,
  44. }: {
  45. slug?: string
  46. modalVisible?: boolean
  47. onClose?: () => void
  48. }) => {
  49. const router = useRouter()
  50. const [codeRedemptionModalVisible, setCodeRedemptionModalVisible] = useState(
  51. modalVisible || false
  52. )
  53. const { data: org, isLoading: isOrgLoading } = useOrganizationQuery({ slug })
  54. const { data: orgBalance, isLoading: isOrgBalanceLoading } = useOrgBalanceQuery(
  55. { orgSlug: slug },
  56. { enabled: codeRedemptionModalVisible }
  57. )
  58. const combinedCreditBalanceCents = orgBalance?.total_balance_cents
  59. const { can: canRedeemCode, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
  60. PermissionAction.BILLING_WRITE,
  61. 'stripe.subscriptions',
  62. undefined,
  63. { organizationSlug: slug }
  64. )
  65. const captchaRef = useRef<HCaptcha>(null)
  66. const captchaTokenRef = useRef<string | null>(null)
  67. const codeRedemptionDisabled =
  68. !canRedeemCode || !isPermissionsLoaded || isOrgLoading || isOrgBalanceLoading
  69. const form = useForm<CreditCodeRedemptionForm>({
  70. resolver: zodResolver(FormSchema as any),
  71. defaultValues: { code: '' },
  72. })
  73. const { isValid } = form.formState
  74. const {
  75. mutate: redeemCode,
  76. isPending: redeemingCode,
  77. error: errorRedeemingCode,
  78. data: codeRedemptionResult,
  79. reset: resetCodeRedemption,
  80. } = useOrganizationCreditCodeRedemptionMutation({
  81. onSuccess: () => {
  82. form.setValue('code', '')
  83. resetCaptcha()
  84. },
  85. })
  86. const resetCaptcha = () => {
  87. captchaTokenRef.current = null
  88. captchaRef.current?.resetCaptcha()
  89. }
  90. const initHcaptcha = async () => {
  91. let token = captchaTokenRef.current
  92. try {
  93. if (!token) {
  94. const captchaResponse = await captchaRef.current?.execute({ async: true })
  95. token = captchaResponse?.response ?? null
  96. captchaTokenRef.current = token
  97. return token
  98. }
  99. } catch (error) {
  100. return token
  101. }
  102. return token
  103. }
  104. const initHcaptchaRef = useLatest(initHcaptcha)
  105. const onSubmit: SubmitHandler<CreditCodeRedemptionForm> = async ({ code }) => {
  106. const token = await initHcaptcha()
  107. redeemCode({ slug, code, hcaptchaToken: token })
  108. }
  109. const onCodeRedemptionDialogVisibilityChange = (visible: boolean) => {
  110. setCodeRedemptionModalVisible(visible)
  111. if (!visible) {
  112. resetCodeRedemption()
  113. resetCaptcha()
  114. onClose?.()
  115. }
  116. }
  117. useEffect(() => {
  118. if (!router.isReady) return
  119. const queryCode = router.query.code
  120. const codeFromParams = Array.isArray(queryCode) ? queryCode[0] : queryCode
  121. if (typeof codeFromParams === 'string' && codeFromParams.trim().length > 2) {
  122. form.setValue('code', codeFromParams)
  123. }
  124. }, [router.isReady, router.query.code, form])
  125. useEffect(() => {
  126. if (codeRedemptionModalVisible) {
  127. initHcaptchaRef.current()
  128. }
  129. }, [codeRedemptionModalVisible, initHcaptchaRef])
  130. return (
  131. <Dialog open={codeRedemptionModalVisible} onOpenChange={onCodeRedemptionDialogVisibilityChange}>
  132. {!modalVisible && (
  133. <DialogTrigger asChild>
  134. <ButtonTooltip
  135. type="default"
  136. className="pointer-events-auto"
  137. disabled={codeRedemptionDisabled}
  138. tooltip={{
  139. content: {
  140. side: 'bottom',
  141. text:
  142. isPermissionsLoaded && !canRedeemCode
  143. ? 'You need additional permissions to redeem codes'
  144. : undefined,
  145. },
  146. }}
  147. >
  148. Redeem Code
  149. </ButtonTooltip>
  150. </DialogTrigger>
  151. )}
  152. <DialogContent size="medium" onInteractOutside={(e) => e.preventDefault()}>
  153. <HCaptcha
  154. ref={captchaRef}
  155. sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
  156. size="invisible"
  157. onOpen={() => {
  158. // [Joshen] This is to ensure that hCaptcha popup remains clickable
  159. if (document !== undefined) document.body.classList.add('pointer-events-auto!')
  160. }}
  161. onClose={() => {
  162. if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
  163. }}
  164. onVerify={(token) => {
  165. captchaTokenRef.current = token
  166. if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
  167. }}
  168. onExpire={() => {
  169. captchaTokenRef.current = null
  170. }}
  171. />
  172. {!!codeRedemptionResult ? (
  173. <div className="p-8">
  174. <div className="text-center flex items-center justify-center">
  175. <PartyPopper strokeWidth={1} className="h-14 w-14" />
  176. </div>
  177. <div className="text-center">
  178. <p className=" text-lg mt-2">Credits redeemed!</p>
  179. </div>
  180. <Separator className="my-4" />
  181. <div className="flex w-full justify-center items-center">
  182. <div className="flex items-center space-x-1">
  183. <p className="opacity-50 text-sm">$</p>
  184. <p className="text-2xl">{codeRedemptionResult.amount_cents / 100}</p>
  185. <p className="opacity-50 text-sm"> credits applied</p>
  186. </div>
  187. </div>
  188. {codeRedemptionResult.credits_expire_at && (
  189. <div className="mt-2 flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 py-3 px-4 rounded-lg">
  190. <Calendar className="h-4 w-4" />
  191. <span>
  192. Expires on{' '}
  193. <TimestampInfo
  194. className="text-sm"
  195. utcTimestamp={codeRedemptionResult.credits_expire_at}
  196. labelFormat="MMMM DD, YYYY"
  197. />
  198. </span>
  199. </div>
  200. )}
  201. {(!router.pathname.includes('/org/') || org?.plan.id === 'free') && (
  202. <div className="mt-4 flex flex-col gap-y-4">
  203. <Separator />
  204. <div className="flex justify-center items-center gap-x-2">
  205. {org?.plan.id === 'free' && (
  206. <UpgradePlanButton plan="Pro" source="code-redeem" slug={org.slug}>
  207. Upgrade organization
  208. </UpgradePlanButton>
  209. )}
  210. {!router.pathname.includes('/org/') && (
  211. <Button asChild type="default">
  212. <Link href={`/org/${org?.slug}`}>Go to organization</Link>
  213. </Button>
  214. )}
  215. </div>
  216. </div>
  217. )}
  218. </div>
  219. ) : (
  220. <>
  221. <DialogHeader>
  222. <DialogTitle>Redeem Code</DialogTitle>
  223. <DialogDescription className="space-y-2">
  224. Redeem your credit code to add credits to your organization
  225. </DialogDescription>
  226. </DialogHeader>
  227. <DialogSectionSeparator />
  228. <Form {...form}>
  229. {isOrgLoading || isOrgBalanceLoading || !isPermissionsLoaded ? (
  230. <div className="p-6 space-y-4">
  231. <ShimmeringLoader />
  232. <div className="flex space-x-4">
  233. <ShimmeringLoader className="w-1/2" />
  234. <ShimmeringLoader className="w-1/2" />
  235. </div>
  236. </div>
  237. ) : (
  238. <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
  239. <DialogSection className="flex flex-col gap-2">
  240. <FormField
  241. control={form.control}
  242. name="code"
  243. render={({ field }) => (
  244. <FormItemLayout
  245. hideMessage
  246. label="Code"
  247. className="gap-1"
  248. layout="horizontal"
  249. >
  250. <Input
  251. {...field}
  252. className="uppercase w-56 ml-auto"
  253. placeholder="ABCD-1234-EFGH-5678"
  254. />
  255. </FormItemLayout>
  256. )}
  257. />
  258. {combinedCreditBalanceCents !== undefined && combinedCreditBalanceCents > 0 && (
  259. <div className="flex w-full justify-between items-center">
  260. <span className="text-sm">Current Balance</span>
  261. <div className="flex items-center gap-x-1">
  262. <p className="opacity-50 text-sm">$</p>
  263. <p className="text-2xl">{combinedCreditBalanceCents / 100}</p>
  264. <p className="opacity-50 text-sm">/credits</p>
  265. </div>
  266. </div>
  267. )}
  268. <Admonition type="note" title="Potential future charges">
  269. <p>
  270. Credits are applied to <strong>{org?.name}</strong> only and cannot be
  271. shared or transferred to other organizations. Credits are automatically used
  272. toward invoices.
  273. </p>
  274. <p className="mt-2">
  275. When credits run out on a paid plan, your default payment method will be
  276. charged—your plan won't be downgraded automatically.
  277. </p>
  278. </Admonition>
  279. {errorRedeemingCode && (
  280. <Admonition
  281. type="warning"
  282. title="Unable to redeem code"
  283. description={errorRedeemingCode?.message}
  284. />
  285. )}
  286. </DialogSection>
  287. <DialogFooter>
  288. <ButtonTooltip
  289. type="primary"
  290. className="pointer-events-auto"
  291. loading={redeemingCode}
  292. disabled={codeRedemptionDisabled || !isValid}
  293. htmlType="submit"
  294. tooltip={{
  295. content: {
  296. side: 'bottom',
  297. text:
  298. isPermissionsLoaded && !canRedeemCode
  299. ? 'You need additional permissions to redeem codes'
  300. : undefined,
  301. },
  302. }}
  303. >
  304. Redeem
  305. </ButtonTooltip>
  306. </DialogFooter>
  307. </form>
  308. )}
  309. </Form>
  310. </>
  311. )}
  312. </DialogContent>
  313. </Dialog>
  314. )
  315. }