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