mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(credits): code redemption site (#42496)
This commit is contained in:
1 parent
5e2e6690ad
commit
e14a048416
6 files changed
+227
-53
No files matched your search
+28
-13
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
Reference in new issue
Block a user