CreditCodeRedemption.tsx348 lines · main
| 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 | |
| 29 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 30 | import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' |
| 31 | import { useOrganizationCreditCodeRedemptionMutation } from '@/data/organizations/organization-credit-code-redemption-mutation' |
| 32 | import { useOrganizationQuery } from '@/data/organizations/organization-query' |
| 33 | import { useOrgBalanceQuery } from '@/data/subscriptions/org-balance-query' |
| 34 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 35 | import { useLatest } from '@/hooks/misc/useLatest' |
| 36 | |
| 37 | const FORM_ID = 'credit-code-redemption' |
| 38 | |
| 39 | const FormSchema = z.object({ |
| 40 | code: z.string().min(1, 'Code is required'), |
| 41 | }) |
| 42 | |
| 43 | type CreditCodeRedemptionForm = z.infer<typeof FormSchema> |
| 44 | |
| 45 | export 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 | } |