| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- import HCaptcha from '@hcaptcha/react-hcaptcha'
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { Calendar, PartyPopper } from 'lucide-react'
- import Link from 'next/link'
- import { useRouter } from 'next/router'
- import { useEffect, useRef, useState } from 'react'
- import { SubmitHandler, useForm } from 'react-hook-form'
- import {
- Button,
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogSection,
- DialogSectionSeparator,
- DialogTitle,
- DialogTrigger,
- Form,
- FormField,
- Input,
- Separator,
- } from 'ui'
- import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { z } from 'zod'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
- import { useOrganizationCreditCodeRedemptionMutation } from '@/data/organizations/organization-credit-code-redemption-mutation'
- import { useOrganizationQuery } from '@/data/organizations/organization-query'
- import { useOrgBalanceQuery } from '@/data/subscriptions/org-balance-query'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useLatest } from '@/hooks/misc/useLatest'
- const FORM_ID = 'credit-code-redemption'
- const FormSchema = z.object({
- code: z.string().min(1, 'Code is required'),
- })
- type CreditCodeRedemptionForm = z.infer<typeof FormSchema>
- export const CreditCodeRedemption = ({
- slug,
- modalVisible = false,
- onClose,
- }: {
- slug?: string
- modalVisible?: boolean
- onClose?: () => void
- }) => {
- const router = useRouter()
- const [codeRedemptionModalVisible, setCodeRedemptionModalVisible] = useState(
- modalVisible || false
- )
- const { data: org, isLoading: isOrgLoading } = useOrganizationQuery({ slug })
- const { data: orgBalance, isLoading: isOrgBalanceLoading } = useOrgBalanceQuery(
- { orgSlug: slug },
- { enabled: codeRedemptionModalVisible }
- )
- const combinedCreditBalanceCents = orgBalance?.total_balance_cents
- const { can: canRedeemCode, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
- PermissionAction.BILLING_WRITE,
- 'stripe.subscriptions',
- undefined,
- { organizationSlug: slug }
- )
- const captchaRef = useRef<HCaptcha>(null)
- const captchaTokenRef = useRef<string | null>(null)
- const codeRedemptionDisabled =
- !canRedeemCode || !isPermissionsLoaded || isOrgLoading || isOrgBalanceLoading
- const form = useForm<CreditCodeRedemptionForm>({
- resolver: zodResolver(FormSchema as any),
- defaultValues: { code: '' },
- })
- const { isValid } = form.formState
- const {
- mutate: redeemCode,
- isPending: redeemingCode,
- error: errorRedeemingCode,
- data: codeRedemptionResult,
- reset: resetCodeRedemption,
- } = useOrganizationCreditCodeRedemptionMutation({
- onSuccess: () => {
- form.setValue('code', '')
- resetCaptcha()
- },
- })
- const resetCaptcha = () => {
- captchaTokenRef.current = null
- captchaRef.current?.resetCaptcha()
- }
- const initHcaptcha = async () => {
- let token = captchaTokenRef.current
- try {
- if (!token) {
- const captchaResponse = await captchaRef.current?.execute({ async: true })
- token = captchaResponse?.response ?? null
- captchaTokenRef.current = token
- return token
- }
- } catch (error) {
- return token
- }
- return token
- }
- const initHcaptchaRef = useLatest(initHcaptcha)
- const onSubmit: SubmitHandler<CreditCodeRedemptionForm> = async ({ code }) => {
- const token = await initHcaptcha()
- redeemCode({ slug, code, hcaptchaToken: token })
- }
- const onCodeRedemptionDialogVisibilityChange = (visible: boolean) => {
- setCodeRedemptionModalVisible(visible)
- if (!visible) {
- resetCodeRedemption()
- resetCaptcha()
- onClose?.()
- }
- }
- useEffect(() => {
- if (!router.isReady) return
- const queryCode = router.query.code
- const codeFromParams = Array.isArray(queryCode) ? queryCode[0] : queryCode
- if (typeof codeFromParams === 'string' && codeFromParams.trim().length > 2) {
- form.setValue('code', codeFromParams)
- }
- }, [router.isReady, router.query.code, form])
- useEffect(() => {
- if (codeRedemptionModalVisible) {
- initHcaptchaRef.current()
- }
- }, [codeRedemptionModalVisible, initHcaptchaRef])
- return (
- <Dialog open={codeRedemptionModalVisible} onOpenChange={onCodeRedemptionDialogVisibilityChange}>
- {!modalVisible && (
- <DialogTrigger asChild>
- <ButtonTooltip
- type="default"
- className="pointer-events-auto"
- disabled={codeRedemptionDisabled}
- tooltip={{
- content: {
- side: 'bottom',
- text:
- isPermissionsLoaded && !canRedeemCode
- ? 'You need additional permissions to redeem codes'
- : undefined,
- },
- }}
- >
- Redeem Code
- </ButtonTooltip>
- </DialogTrigger>
- )}
- <DialogContent size="medium" onInteractOutside={(e) => e.preventDefault()}>
- <HCaptcha
- ref={captchaRef}
- sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
- size="invisible"
- onOpen={() => {
- // [Joshen] This is to ensure that hCaptcha popup remains clickable
- if (document !== undefined) document.body.classList.add('pointer-events-auto!')
- }}
- onClose={() => {
- if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
- }}
- onVerify={(token) => {
- captchaTokenRef.current = token
- if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
- }}
- onExpire={() => {
- captchaTokenRef.current = null
- }}
- />
- {!!codeRedemptionResult ? (
- <div className="p-8">
- <div className="text-center flex items-center justify-center">
- <PartyPopper strokeWidth={1} className="h-14 w-14" />
- </div>
- <div className="text-center">
- <p className=" text-lg mt-2">Credits redeemed!</p>
- </div>
- <Separator className="my-4" />
- <div className="flex w-full justify-center items-center">
- <div className="flex items-center space-x-1">
- <p className="opacity-50 text-sm">$</p>
- <p className="text-2xl">{codeRedemptionResult.amount_cents / 100}</p>
- <p className="opacity-50 text-sm"> credits applied</p>
- </div>
- </div>
- {codeRedemptionResult.credits_expire_at && (
- <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">
- <Calendar className="h-4 w-4" />
- <span>
- Expires on{' '}
- <TimestampInfo
- className="text-sm"
- utcTimestamp={codeRedemptionResult.credits_expire_at}
- labelFormat="MMMM DD, YYYY"
- />
- </span>
- </div>
- )}
- {(!router.pathname.includes('/org/') || org?.plan.id === 'free') && (
- <div className="mt-4 flex flex-col gap-y-4">
- <Separator />
- <div className="flex justify-center items-center gap-x-2">
- {org?.plan.id === 'free' && (
- <UpgradePlanButton plan="Pro" source="code-redeem" slug={org.slug}>
- Upgrade organization
- </UpgradePlanButton>
- )}
- {!router.pathname.includes('/org/') && (
- <Button asChild type="default">
- <Link href={`/org/${org?.slug}`}>Go to organization</Link>
- </Button>
- )}
- </div>
- </div>
- )}
- </div>
- ) : (
- <>
- <DialogHeader>
- <DialogTitle>Redeem Code</DialogTitle>
- <DialogDescription className="space-y-2">
- Redeem your credit code to add credits to your organization
- </DialogDescription>
- </DialogHeader>
- <DialogSectionSeparator />
- <Form {...form}>
- {isOrgLoading || isOrgBalanceLoading || !isPermissionsLoaded ? (
- <div className="p-6 space-y-4">
- <ShimmeringLoader />
- <div className="flex space-x-4">
- <ShimmeringLoader className="w-1/2" />
- <ShimmeringLoader className="w-1/2" />
- </div>
- </div>
- ) : (
- <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
- <DialogSection className="flex flex-col gap-2">
- <FormField
- control={form.control}
- name="code"
- render={({ field }) => (
- <FormItemLayout
- hideMessage
- label="Code"
- className="gap-1"
- layout="horizontal"
- >
- <Input
- {...field}
- className="uppercase w-56 ml-auto"
- placeholder="ABCD-1234-EFGH-5678"
- />
- </FormItemLayout>
- )}
- />
- {combinedCreditBalanceCents !== undefined && combinedCreditBalanceCents > 0 && (
- <div className="flex w-full justify-between items-center">
- <span className="text-sm">Current Balance</span>
- <div className="flex items-center gap-x-1">
- <p className="opacity-50 text-sm">$</p>
- <p className="text-2xl">{combinedCreditBalanceCents / 100}</p>
- <p className="opacity-50 text-sm">/credits</p>
- </div>
- </div>
- )}
- <Admonition type="note" title="Potential future charges">
- <p>
- Credits are applied to <strong>{org?.name}</strong> only and cannot be
- shared or transferred to other organizations. Credits are automatically used
- toward invoices.
- </p>
- <p className="mt-2">
- When credits run out on a paid plan, your default payment method will be
- charged—your plan won't be downgraded automatically.
- </p>
- </Admonition>
- {errorRedeemingCode && (
- <Admonition
- type="warning"
- title="Unable to redeem code"
- description={errorRedeemingCode?.message}
- />
- )}
- </DialogSection>
- <DialogFooter>
- <ButtonTooltip
- type="primary"
- className="pointer-events-auto"
- loading={redeemingCode}
- disabled={codeRedemptionDisabled || !isValid}
- htmlType="submit"
- tooltip={{
- content: {
- side: 'bottom',
- text:
- isPermissionsLoaded && !canRedeemCode
- ? 'You need additional permissions to redeem codes'
- : undefined,
- },
- }}
- >
- Redeem
- </ButtonTooltip>
- </DialogFooter>
- </form>
- )}
- </Form>
- </>
- )}
- </DialogContent>
- </Dialog>
- )
- }
|