From e14a0484162af6ba51058114cfb43f1add808c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Fri, 6 Feb 2026 13:44:49 +0530 Subject: [PATCH] feat(credits): code redemption site (#42496) --- .../BillingSettings/CreditCodeRedemption.tsx | 41 +++++--- .../Organization/OrganizationCard.tsx | 76 ++++++++------- .../layouts/RedeemCreditsLayout.tsx | 52 ++++++++++ ...ization-credit-code-redemption-mutation.ts | 3 - apps/studio/pages/redeem.tsx | 97 +++++++++++++++++++ apps/www/lib/redirects.js | 11 +++ 6 files changed, 227 insertions(+), 53 deletions(-) create mode 100644 apps/studio/components/layouts/RedeemCreditsLayout.tsx create mode 100644 apps/studio/pages/redeem.tsx diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx index 49c6c0b0d6b..d34d78f8720 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx @@ -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 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 = ({ Redeem Code -

- Redeem your credit code to add credits to your organization -

+ Redeem your credit code to add credits to your organization
- {isOrgLoading || isCustomerProfileLoading ? ( + {isOrgLoading || isCustomerProfileLoading || !isPermissionsLoaded ? (
@@ -321,14 +326,24 @@ export const CreditCodeRedemption = ({ - + )} diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx index 7ec30cf806c..d5d95a8c7dc 100644 --- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -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 ( - - div]:w-full [&>div]:items-center', - className - )} - icon={} - title={organization.name} - description={ -
-
- {organization.plan.name} Plan - {numProjects > 0 && ( - <> - • - - {numProjects} project{numProjects > 1 ? 's' : ''} - - - )} -
- {isMfaRequired && ( - - - - - - MFA enforced - - + const renderContent = () => ( + div]:w-full [&>div]:items-center', + className + )} + icon={} + title={organization.name} + description={ +
+
+ {organization.plan.name} Plan + {numProjects > 0 && ( + <> + • + + {numProjects} project{numProjects > 1 ? 's' : ''} + + )}
- } - /> - + {isMfaRequired && ( + + + + + + MFA enforced + + + )} +
+ } + /> ) + + if (isLink) { + return {renderContent()} + } else { + return {renderContent()} + } } diff --git a/apps/studio/components/layouts/RedeemCreditsLayout.tsx b/apps/studio/components/layouts/RedeemCreditsLayout.tsx new file mode 100644 index 00000000000..f2db6eafae9 --- /dev/null +++ b/apps/studio/components/layouts/RedeemCreditsLayout.tsx @@ -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) => { + const { resolvedTheme } = useTheme() + const { appTitle } = useCustomContent(['app:title']) + + return ( + <> + + Redeem Credits | {appTitle || 'Supabase'} + +
+
+
+
+
+
+ Supabase + Supabase Logo +
+
+
+
+
+ +
+ {children} +
+
+ + ) +} + +export default withAuth(RedeemCreditsLayout) diff --git a/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts b/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts index 20e8c5b8f79..d8ece7ba77c 100644 --- a/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts +++ b/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts @@ -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) { diff --git a/apps/studio/pages/redeem.tsx b/apps/studio/pages/redeem.tsx new file mode 100644 index 00000000000..4518951f057 --- /dev/null +++ b/apps/studio/pages/redeem.tsx @@ -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(null) + + if (!isOrganizationsFetched) { + return ( + +
+ + +
+ + +
+
+
+ ) + } + + if (!wasOrganizationsRequestSuccessful) { + return

Error loading redeem credit page. Try again later.

+ } + + return ( +
+
+

+ 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. +

+

+ Want to start fresh? Create a new + organization first. You will have to revisit this link after creating the organization to + redeem the code. +

+ +
+ +
+ {organizations?.map((org) => ( +
setSelectedOrg(org.slug)}> + +
+ ))} +
+ + {selectedOrg && ( + setSelectedOrg(null)} + /> + )} +
+ ) +} + +const RedeemCreditsPage: NextPageWithLayout = () => { + return ( + + + Code Redemption + + + + + ) +} + +RedeemCreditsPage.getLayout = (page) => {page} + +export default RedeemCreditsPage diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 9402eb8ec42..ef290174e0d 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -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, + }, ]