feat(credits): code redemption site (#42496)

This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2026-02-06 13:44:49 +05:30
1 parent 5e2e6690ad
commit e14a048416
6 files changed
+227 -53

No files matched your search

@@ -11,9 +11,9 @@ import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Dialog,
DialogContent,
@@ -24,8 +24,8 @@ import {
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Separator,
} from 'ui'
@@ -35,8 +35,8 @@ import { z } from 'zod'
import { useOrganizationCreditCodeRedemptionMutation } from '@/data/organizations/organization-credit-code-redemption-mutation'
import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
import { useOrganizationQuery } from '@/data/organizations/organization-query'
import useLatest from '@/hooks/misc/useLatest'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
const FORM_ID = 'credit-code-redemption'
@@ -49,18 +49,24 @@ type CreditCodeRedemptionForm = z.infer<typeof FormSchema>
export const CreditCodeRedemption = ({
slug,
modalVisible = false,
onClose,
}: {
slug: string | undefined
modalVisible?: boolean
onClose?: () => void
}) => {
const { can: canRedeemCode, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
'stripe.subscriptions',
undefined,
{
organizationSlug: slug,
}
)
const redeemCodeEnabled = useFlag('redeemCodeEnabled')
const { data: org, isLoading: isOrgLoading } = useSelectedOrganizationQuery({})
const { data: org, isLoading: isOrgLoading } = useOrganizationQuery({ slug })
const router = useRouter()
@@ -138,7 +144,7 @@ export const CreditCodeRedemption = ({
hcaptchaToken: token,
},
{
onSuccess: (data) => {
onSuccess: () => {
form.setValue('code', '')
resetCaptcha()
},
@@ -151,6 +157,7 @@ export const CreditCodeRedemption = ({
if (!visible) {
resetCodeRedemption()
resetCaptcha()
onClose?.()
}
}
@@ -253,16 +260,14 @@ export const CreditCodeRedemption = ({
<DialogHeader>
<DialogTitle>Redeem Code</DialogTitle>
<DialogDescription className="space-y-2">
<p className="prose text-sm">
Redeem your credit code to add credits to your organization
</p>
Redeem your credit code to add credits to your organization
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<Form_Shadcn_ {...form}>
{isOrgLoading || isCustomerProfileLoading ? (
{isOrgLoading || isCustomerProfileLoading || !isPermissionsLoaded ? (
<div className="p-6 space-y-4">
<ShimmeringLoader />
<div className="flex space-x-4">
@@ -321,14 +326,24 @@ export const CreditCodeRedemption = ({
</DialogSection>
<DialogFooter>
<Button
htmlType="submit"
<ButtonTooltip
type="primary"
className="pointer-events-auto"
loading={redeemingCode}
disabled={codeRedemptionDisabled}
htmlType="submit"
tooltip={{
content: {
side: 'bottom',
text:
isPermissionsLoaded && !canRedeemCode
? 'You need additional permissions to redeem codes'
: undefined,
},
}}
>
Redeem
</Button>
</ButtonTooltip>
</DialogFooter>
</form>
)}
@@ -24,44 +24,46 @@ export const OrganizationCard = ({
const numProjects = data?.pages[0].pagination.count ?? 0
const isMfaRequired = organization.organization_requires_mfa
const Parent = isLink ? Link : Fragment
return (
<Parent href={href ?? `/org/${organization.slug}`}>
<ActionCard
bgColor="bg border"
className={cn(
'flex items-center min-h-[70px] [&>div]:w-full [&>div]:items-center',
className
)}
icon={<Boxes size={18} strokeWidth={1} className="text-foreground" />}
title={organization.name}
description={
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
<div className="flex items-center gap-x-1.5">
<span>{organization.plan.name} Plan</span>
{numProjects > 0 && (
<>
<span>•</span>
<span>
{numProjects} project{numProjects > 1 ? 's' : ''}
</span>
</>
)}
</div>
{isMfaRequired && (
<Tooltip>
<TooltipTrigger className="cursor-default">
<Lock size={12} />
</TooltipTrigger>
<TooltipContent side="bottom" className={!isUserMFAEnabled ? 'w-80' : ''}>
MFA enforced
</TooltipContent>
</Tooltip>
const renderContent = () => (
<ActionCard
bgColor="bg border"
className={cn(
'flex items-center min-h-[70px] [&>div]:w-full [&>div]:items-center',
className
)}
icon={<Boxes size={18} strokeWidth={1} className="text-foreground" />}
title={organization.name}
description={
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
<div className="flex items-center gap-x-1.5">
<span>{organization.plan.name} Plan</span>
{numProjects > 0 && (
<>
<span>•</span>
<span>
{numProjects} project{numProjects > 1 ? 's' : ''}
</span>
</>
)}
</div>
}
/>
</Parent>
{isMfaRequired && (
<Tooltip>
<TooltipTrigger className="cursor-default">
<Lock size={12} />
</TooltipTrigger>
<TooltipContent side="bottom" className={!isUserMFAEnabled ? 'w-80' : ''}>
MFA enforced
</TooltipContent>
</Tooltip>
)}
</div>
}
/>
)
if (isLink) {
return <Fragment>{renderContent()}</Fragment>
} else {
return <Link href={href ?? `/org/${organization.slug}`}>{renderContent()}</Link>
}
}
@@ -0,0 +1,52 @@
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import Head from 'next/head'
import Image from 'next/legacy/image'
import type { PropsWithChildren } from 'react'
import { Separator } from 'ui'
import { withAuth } from '../../hooks/misc/withAuth'
export interface RedeemCreditsLayoutProps {}
const RedeemCreditsLayout = ({ children }: PropsWithChildren<RedeemCreditsLayoutProps>) => {
const { resolvedTheme } = useTheme()
const { appTitle } = useCustomContent(['app:title'])
return (
<>
<Head>
<title>Redeem Credits | {appTitle || 'Supabase'}</title>
</Head>
<main className="flex flex-col flex-grow w-full h-full overflow-y-auto">
<div>
<div className="mx-auto px-4 sm:px-6">
<div className="max-w-xl flex justify-between items-center py-4">
<div className="flex justify-start lg:w-0 lg:flex-1">
<div>
<span className="sr-only">Supabase</span>
<Image
src={
resolvedTheme?.includes('dark')
? `${BASE_PATH}/img/supabase-dark.svg`
: `${BASE_PATH}/img/supabase-light.svg`
}
alt="Supabase Logo"
height={20}
width={105}
/>
</div>
</div>
</div>
</div>
</div>
<Separator />
<div className="flex flex-col justify-center flex-grow mx-auto w-[90vw] space-y-4">
{children}
</div>
</main>
</>
)
}
export default withAuth(RedeemCreditsLayout)
@@ -64,9 +64,6 @@ export const useOrganizationCreditCodeRedemptionMutation = ({
await queryClient.invalidateQueries({ queryKey: organizationKeys.customerProfile(slug) })
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgSubscription(slug) })
await queryClient.invalidateQueries({
queryKey: organizationKeys.previewCreditCode(slug, code),
})
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
+97
View File
@@ -0,0 +1,97 @@
import Link from 'next/link'
import { useState } from 'react'
import { Button, Loading } from 'ui'
import { ShimmeringLoader } from 'ui-patterns'
import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption'
import { OrganizationCard } from '@/components/interfaces/Organization/OrganizationCard'
import RedeemCreditsLayout from '@/components/layouts/RedeemCreditsLayout'
import {
ScaffoldContainer,
ScaffoldDivider,
ScaffoldHeader,
ScaffoldTitle,
} from '@/components/layouts/Scaffold'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import type { NextPageWithLayout } from '@/types'
const RedeemCreditsContent = () => {
const {
data: organizations,
isLoading: areOrganizationsLoading,
isFetched: isOrganizationsFetched,
isSuccess: wasOrganizationsRequestSuccessful,
} = useOrganizationsQuery()
const [selectedOrg, setSelectedOrg] = useState<string | null>(null)
if (!isOrganizationsFetched) {
return (
<Loading active={areOrganizationsLoading}>
<div className="grid md:grid-cols-2 pt-10 gap-8">
<ShimmeringLoader className="w-full" />
<div className="space-y-4">
<ShimmeringLoader className="w-full h-14" />
<ShimmeringLoader className="w-full h-14" />
</div>
</div>
</Loading>
)
}
if (!wasOrganizationsRequestSuccessful) {
return <p className="mt-4">Error loading redeem credit page. Try again later.</p>
}
return (
<div className="grid md:grid-cols-2 pt-10 gap-8">
<div>
<p>
To redeem your credits, select one of your organizations. The credits will be applied to
that organization only and cannot be transferred or shared between organizations.
</p>
<p className="mt-8">
<span className="font-bold text-foreground-light">Want to start fresh?</span> Create a new
organization first. You will have to revisit this link after creating the organization to
redeem the code.
</p>
<Button asChild className="mt-4" size="tiny" type="primary">
<Link href={`/new`}>Create organization</Link>
</Button>
</div>
<div className="space-y-2">
{organizations?.map((org) => (
<div key={org.id} onClickCapture={() => setSelectedOrg(org.slug)}>
<OrganizationCard isLink={false} organization={org} />
</div>
))}
</div>
{selectedOrg && (
<CreditCodeRedemption
slug={selectedOrg}
modalVisible={true}
onClose={() => setSelectedOrg(null)}
/>
)}
</div>
)
}
const RedeemCreditsPage: NextPageWithLayout = () => {
return (
<ScaffoldContainer>
<ScaffoldHeader>
<ScaffoldTitle>Code Redemption</ScaffoldTitle>
</ScaffoldHeader>
<ScaffoldDivider />
<RedeemCreditsContent />
</ScaffoldContainer>
)
}
RedeemCreditsPage.getLayout = (page) => <RedeemCreditsLayout>{page}</RedeemCreditsLayout>
export default RedeemCreditsPage
+11
View File
@@ -3081,4 +3081,15 @@ module.exports = [
source: '/design',
destination: '/design-system',
},
{
source: '/redeem',
has: [
{
type: 'query',
key: 'code',
},
],
destination: '/dashboard/redeem?code=:code',
permanent: false,
},
]