diff --git a/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx
new file mode 100644
index 00000000000..7d2a98f7216
--- /dev/null
+++ b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx
@@ -0,0 +1,180 @@
+import { LOCAL_STORAGE_KEYS } from 'common'
+import { Check, ChevronDown } from 'lucide-react'
+import { useMemo, useState, type ReactNode } from 'react'
+import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
+
+import {
+ CreateOrganizationCard,
+ OrganizationCard,
+} from '@/components/interfaces/Organization/OrganizationCard'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
+import type { Organization } from '@/types'
+
+const VISIBLE_ORGANIZATIONS_LIMIT = 3
+const CONNECT_DISCLOSURE_TRIGGER_CLASSNAME =
+ 'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground'
+
+export const OrganizationSelector = ({
+ organizations,
+ selectedSlug,
+ disabled = false,
+ description,
+ createLabel,
+ createHrefParams,
+ onSelect,
+ getOrganizationDescription,
+}: {
+ organizations: Organization[]
+ selectedSlug?: string | null
+ disabled?: boolean
+ description?: ReactNode
+ createLabel?: string
+ createHrefParams?: { [key: string]: string }
+ onSelect: (slug: string) => void
+ getOrganizationDescription?: (organization: Organization) => ReactNode
+}) => {
+ const [showMore, setShowMore] = useState(false)
+ const [lastVisitedOrganization] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
+ ''
+ )
+
+ const { visibleOrganizations, overflowOrganizations } = useMemo(() => {
+ const lastVisitedOrg = organizations.find(({ slug }) => slug === lastVisitedOrganization)
+ const selectedIndex = organizations.findIndex(({ slug }) => slug === selectedSlug)
+ const selectedInOverflow = selectedIndex >= VISIBLE_ORGANIZATIONS_LIMIT
+
+ if (!!lastVisitedOrg) {
+ const withoutLastVisited = organizations.filter(
+ ({ slug }) => slug !== lastVisitedOrganization
+ )
+ return {
+ visibleOrganizations: [
+ lastVisitedOrg,
+ ...withoutLastVisited.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
+ ],
+ overflowOrganizations: withoutLastVisited.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
+ }
+ }
+
+ if (!selectedInOverflow || !selectedSlug) {
+ return {
+ visibleOrganizations: organizations.slice(0, VISIBLE_ORGANIZATIONS_LIMIT),
+ overflowOrganizations: organizations.slice(VISIBLE_ORGANIZATIONS_LIMIT),
+ }
+ }
+
+ const selected = organizations[selectedIndex]
+ const withoutSelected = organizations.filter(({ slug }) => slug !== selectedSlug)
+
+ return {
+ visibleOrganizations: [
+ ...withoutSelected.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
+ selected,
+ ],
+ overflowOrganizations: withoutSelected.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
+ }
+ }, [lastVisitedOrganization, organizations, selectedSlug])
+
+ const hasOverflow = overflowOrganizations.length > 0
+
+ return (
+
+
+
+ Organization
+
+ {description &&
{description}
}
+
+
+ {visibleOrganizations.map((organization) => (
+
onSelect(organization.slug)}
+ description={
+ getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
+ }
+ />
+ ))}
+
+ {!!createLabel && !!createHrefParams && (
+
+ )}
+
+ {hasOverflow && (
+
+
+ {showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`}
+
+
+
+
+ {overflowOrganizations.map((organization) => (
+ onSelect(organization.slug)}
+ description={
+ getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
+ }
+ />
+ ))}
+
+
+
+ )}
+
+
+ )
+}
+
+const getPlanDescription = (organization: Organization) => `${organization.plan.name} Plan`
+
+const ConnectOrganizationButton = ({
+ organization,
+ selected,
+ disabled,
+ onClick,
+ description,
+}: {
+ organization: Organization
+ selected?: boolean
+ disabled?: boolean
+ onClick?: () => void
+ description?: ReactNode
+}) => (
+
+)
diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
index 417d24004c3..b33ecb67d0e 100644
--- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
+++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
@@ -1,7 +1,7 @@
import { useIsMFAEnabled } from 'common'
-import { Boxes, Lock } from 'lucide-react'
+import { Boxes, Lock, Plus } from 'lucide-react'
import Link from 'next/link'
-import { Fragment } from 'react'
+import { Fragment, type ReactNode } from 'react'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { ActionCard } from '@/components/ui/ActionCard'
@@ -15,18 +15,21 @@ export const OrganizationCard = ({
isLink = true,
className,
onClick,
+ description,
}: {
organization: Organization
href?: string
isLink?: boolean
className?: string
onClick?: () => void
+ description?: ReactNode
}) => {
const isUserMFAEnabled = useIsMFAEnabled()
const isPlatformOrg = organization.plan?.id === 'platform'
+ const shouldRenderDefaultDescription = description === undefined
const { data } = useOrgProjectsInfiniteQuery(
{ slug: organization.slug },
- { enabled: !isPlatformOrg }
+ { enabled: !isPlatformOrg && shouldRenderDefaultDescription }
)
const numProjects = data?.pages[0].pagination.count ?? 0
const isMfaRequired = organization.organization_requires_mfa
@@ -42,32 +45,36 @@ export const OrganizationCard = ({
title={organization.name}
onClick={onClick}
description={
-
-
-
{organization.plan.name} Plan
- {numProjects > 0 && (
- <>
-
·
-
- {numProjects} project{numProjects > 1 ? 's' : ''}
-
- >
- )}
+ shouldRenderDefaultDescription ? (
+
+
+ {organization.plan.name} Plan
+ {numProjects > 0 && (
+ <>
+ ·
+
+ {numProjects} project{numProjects > 1 ? 's' : ''}
+
+ >
+ )}
+
+
+
+ {isMfaRequired && (
+
+
+
+
+
+ MFA enforced
+
+
+ )}
+
-
-
- {isMfaRequired && (
-
-
-
-
-
- MFA enforced
-
-
- )}
-
-
+ ) : (
+ description
+ )
}
/>
)
@@ -78,3 +85,27 @@ export const OrganizationCard = ({
return
{renderContent()}
}
}
+
+export const CreateOrganizationCard = ({
+ params = {},
+ label = 'Create new organization',
+}: {
+ params?: { [key: string]: string }
+ label?: string
+}) => {
+ const createOrganizationHref = `/new${Object.keys(params).length > 0 ? `?${new URLSearchParams(params).toString()}` : ''}`
+
+ return (
+
+
div]:w-full [&>div]:items-center max-h-min',
+ 'border-dashed shadow-none transition-colors group-hover:border-default group-hover:bg-surface-200'
+ )}
+ icon={}
+ title={label}
+ />
+
+ )
+}
diff --git a/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx
new file mode 100644
index 00000000000..d9842db90a2
--- /dev/null
+++ b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.test.tsx
@@ -0,0 +1,145 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FeatureFlagContext } from 'common'
+import { HttpResponse } from 'msw'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { RedeemCreditsScreen } from './RedeemCredits'
+import type { ProfileContextType } from '@/lib/profile'
+import { createMockOrganization } from '@/tests/helpers'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+import { routerMock } from '@/tests/lib/route-mock'
+
+const { creditRedemptionProps } = vi.hoisted(() => ({
+ creditRedemptionProps: vi.fn(),
+}))
+const { creditRedemptionQueryCode } = vi.hoisted(() => ({
+ creditRedemptionQueryCode: { current: undefined as string | undefined },
+}))
+
+vi.mock('@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption', () => {
+ return {
+ CreditCodeRedemption: (props: { slug?: string }) => {
+ creditRedemptionProps({ ...props, queryCode: creditRedemptionQueryCode.current })
+ return (
+
+ Credit redemption for {props.slug} with code {creditRedemptionQueryCode.current}
+
+ )
+ },
+ }
+})
+
+const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
+ profile: {
+ id: 1,
+ auth0_id: 'auth0|test',
+ gotrue_id: 'gotrue-test',
+ username: 'testuser',
+ primary_email: 'test@example.com',
+ first_name: null,
+ last_name: null,
+ mobile: null,
+ is_alpha_user: false,
+ is_sso_user: false,
+ disabled_features: [],
+ free_project_limit: null,
+ },
+ error: null,
+ isLoading: false,
+ isError: false,
+ isSuccess: true,
+}
+
+const ORGANIZATION = createMockOrganization({
+ id: 1,
+ name: 'Acme Production',
+ slug: 'acme-production',
+ plan: { id: 'pro', name: 'Pro' },
+})
+
+function renderScreen() {
+ return customRender(
+
+
+ ,
+ { profileContext: DEFAULT_PROFILE_CONTEXT }
+ )
+}
+
+describe('RedeemCreditsScreen', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ creditRedemptionQueryCode.current = undefined
+ routerMock.setCurrentUrl('/redeem')
+ })
+
+ test('renders ready state from organizations query and opens redemption for selected organization', async () => {
+ const user = userEvent.setup()
+ routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123')
+ creditRedemptionQueryCode.current = 'SUPA-CREDIT-123'
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () => HttpResponse.json([ORGANIZATION]),
+ })
+
+ renderScreen()
+
+ await user.click(await screen.findByRole('button', { name: /Acme Production/ }))
+ await user.click(screen.getByRole('button', { name: 'Redeem credits' }))
+
+ expect(await screen.findByTestId('credit-redemption')).toHaveTextContent(
+ 'Credit redemption for acme-production with code SUPA-CREDIT-123'
+ )
+ expect(creditRedemptionProps).toHaveBeenCalledWith(
+ expect.objectContaining({
+ slug: 'acme-production',
+ queryCode: 'SUPA-CREDIT-123',
+ })
+ )
+ })
+
+ test('routes new organization creation back to the current redeem URL', async () => {
+ routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123')
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () => HttpResponse.json([ORGANIZATION]),
+ })
+
+ renderScreen()
+
+ const createOrganizationLink = await screen.findByRole('link', {
+ name: /Create new organization/,
+ })
+
+ expect(createOrganizationLink).toHaveAttribute(
+ 'href',
+ '/new?returnTo=%2Fredeem%3Fcode%3DSUPA-CREDIT-123&returnToOrgParam=selected_org'
+ )
+ })
+
+ test('preselects an organization returned from new organization creation', async () => {
+ const user = userEvent.setup()
+ routerMock.setCurrentUrl('/redeem?code=SUPA-CREDIT-123&selected_org=acme-production')
+ creditRedemptionQueryCode.current = 'SUPA-CREDIT-123'
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () => HttpResponse.json([ORGANIZATION]),
+ })
+
+ renderScreen()
+
+ const redeemButton = await screen.findByRole('button', { name: 'Redeem credits' })
+
+ await waitFor(() => expect(redeemButton).toBeEnabled())
+ await user.click(redeemButton)
+
+ expect(await screen.findByTestId('credit-redemption')).toHaveTextContent(
+ 'Credit redemption for acme-production with code SUPA-CREDIT-123'
+ )
+ })
+})
diff --git a/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx
new file mode 100644
index 00000000000..3b622765a32
--- /dev/null
+++ b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx
@@ -0,0 +1,206 @@
+import { FeatureFlagContext } from 'common'
+import { useRouter } from 'next/router'
+import { ReactNode, useContext, useEffect, useRef, useState } from 'react'
+import { Button, Card, CardContent } from 'ui'
+import { Admonition, ShimmeringLoader } from 'ui-patterns'
+
+import { OrganizationSelector } from '../Connect/OrganizationSelector'
+import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption'
+import {
+ InterstitialAccountRow,
+ InterstitialLayout,
+ SupabaseLogo,
+} from '@/components/layouts/InterstitialLayout'
+import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
+import { useProfile } from '@/lib/profile'
+import { EMPTY_ARR } from '@/lib/void'
+
+const RETURN_TO_SELECTED_ORG_PARAM = 'selected_org'
+
+const RedeemCreditsInterstitial = ({
+ title,
+ description,
+ children,
+}: {
+ title: ReactNode
+ description?: ReactNode
+ children: ReactNode
+}) => (
+ } title={title} description={description}>
+ {children}
+
+)
+
+export const RedeemCreditsScreen = () => {
+ const router = useRouter()
+ const { profile, isLoading: isLoadingProfile } = useProfile()
+ const { hasLoaded } = useContext(FeatureFlagContext)
+
+ const [selectedOrgSlug, setSelectedOrgSlug] = useState(null)
+ const [redemptionModalOrgSlug, setRedemptionModalOrgSlug] = useState(null)
+ const appliedReturnSelectedOrgRef = useRef(null)
+
+ const {
+ data: organizationOptions = EMPTY_ARR,
+ error: organizationsError,
+ isLoading: isLoadingOrganizations,
+ isError: isOrganizationsError,
+ } = useOrganizationsQuery()
+
+ const returnSelectedOrgSlug =
+ router.isReady && typeof router.query[RETURN_TO_SELECTED_ORG_PARAM] === 'string'
+ ? router.query[RETURN_TO_SELECTED_ORG_PARAM]
+ : null
+
+ const displayName = profile?.primary_email ?? profile?.username
+
+ const isLoading = isLoadingProfile || isLoadingOrganizations || !hasLoaded
+
+ useEffect(() => {
+ if (!returnSelectedOrgSlug) return
+ if (appliedReturnSelectedOrgRef.current === returnSelectedOrgSlug) return
+
+ const hasReturnedOrganization = (organizationOptions ?? []).some(
+ (organization) => organization.slug === returnSelectedOrgSlug
+ )
+
+ if (hasReturnedOrganization) {
+ setSelectedOrgSlug(returnSelectedOrgSlug)
+ appliedReturnSelectedOrgRef.current = returnSelectedOrgSlug
+ }
+ }, [organizationOptions, returnSelectedOrgSlug])
+
+ if (isLoading) {
+ return (
+ }
+ description={}
+ >
+
+
+ )
+ }
+
+ if (isOrganizationsError) {
+ return (
+
+
+
+ We could not load your organizations.
+ {organizationsError && (
+
+ Error: {organizationsError.message}
+
+ )}
+ >
+ }
+ />
+
+
+ )
+ }
+
+ const createOrganizationParams = {
+ returnTo: router.asPath || '/redeem',
+ returnToOrgParam: RETURN_TO_SELECTED_ORG_PARAM,
+ }
+
+ const openRedemption = () => {
+ if (!selectedOrgSlug) return
+ setRedemptionModalOrgSlug(selectedOrgSlug)
+ }
+
+ return (
+ <>
+
+
+
+
+
`${organization.plan.name} Plan`}
+ createLabel={
+ organizationOptions.length === 0
+ ? 'Create your first organization'
+ : 'Create new organization'
+ }
+ createHrefParams={createOrganizationParams}
+ />
+
+ {organizationOptions.length === 0 && (
+
+ )}
+
+
+
+
+ Credits apply to one organization and are used toward future invoices before your
+ payment method is charged.
+
+
+
+
+
+ {redemptionModalOrgSlug && (
+ setRedemptionModalOrgSlug(null)}
+ />
+ )}
+ >
+ )
+}
+
+const ConnectLoadingCards = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+)
diff --git a/apps/studio/components/layouts/RedeemCreditsLayout.tsx b/apps/studio/components/layouts/RedeemCreditsLayout.tsx
deleted file mode 100644
index 16c276802c5..00000000000
--- a/apps/studio/components/layouts/RedeemCreditsLayout.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-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'
-import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
-import { BASE_PATH } from '@/lib/constants'
-
-export interface RedeemCreditsLayoutProps {}
-
-const RedeemCreditsLayout = ({ children }: PropsWithChildren) => {
- const { resolvedTheme } = useTheme()
- const { appTitle } = useCustomContent(['app:title'])
-
- return (
- <>
-
- Redeem Credits | {appTitle || 'Supabase'}
-
-
-
-
-
- {children}
-
-
- >
- )
-}
-
-export default withAuth(RedeemCreditsLayout)
diff --git a/apps/studio/components/layouts/Tabs/ActionCard.tsx b/apps/studio/components/layouts/Tabs/ActionCard.tsx
index efc71d1e34f..24f4057997b 100644
--- a/apps/studio/components/layouts/Tabs/ActionCard.tsx
+++ b/apps/studio/components/layouts/Tabs/ActionCard.tsx
@@ -1,12 +1,11 @@
import type { ReactNode } from 'react'
-import { Badge, Card } from 'ui'
+import { Card } from 'ui'
export const ActionCard = (card: {
icon: ReactNode
title: string
description: string
bgColor: string
- isBeta?: boolean
onClick?: () => void
}) => {
return (
@@ -15,11 +14,6 @@ export const ActionCard = (card: {
onClick={card.onClick}
>
- {card.isBeta && (
-
- Coming soon
-
- )}
diff --git a/apps/studio/components/ui/ActionCard.tsx b/apps/studio/components/ui/ActionCard.tsx
index d9555b0c374..76db9426ec0 100644
--- a/apps/studio/components/ui/ActionCard.tsx
+++ b/apps/studio/components/ui/ActionCard.tsx
@@ -1,12 +1,11 @@
import type { ReactNode } from 'react'
-import { Badge, Card, cn } from 'ui'
+import { Card, cn } from 'ui'
export const ActionCard = (card: {
icon: ReactNode
title: string
bgColor?: string
description?: ReactNode
- isBeta?: boolean
className?: string
onClick?: () => void
}) => {
@@ -18,21 +17,18 @@ export const ActionCard = (card: {
)}
onClick={card.onClick}
>
-
- {card.isBeta && (
-
- Coming soon
-
- )}
+
{card.icon}
-
- {card.title}
-
+
+
+ {card.title}
+
+
{typeof card.description === 'string' ? (
{card.description}
) : (
diff --git a/apps/studio/pages/redeem.tsx b/apps/studio/pages/redeem.tsx
index 4e05b203559..546bce43052 100644
--- a/apps/studio/pages/redeem.tsx
+++ b/apps/studio/pages/redeem.tsx
@@ -1,111 +1,26 @@
-import { FeatureFlagContext } from 'common'
-import Link from 'next/link'
-import { useContext, useState } from 'react'
-import { Button } from 'ui'
-import { ShimmeringLoader } from 'ui-patterns'
+import Head from 'next/head'
+import { useRouter } from 'next/router'
-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 AlertError from '@/components/ui/AlertError'
-import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
-import { useProfile } from '@/lib/profile'
+import { RedeemCreditsScreen } from '@/components/interfaces/RedeemCredits/RedeemCredits'
+import { withAuth } from '@/hooks/misc/withAuth'
+import { buildStudioPageTitle } from '@/lib/page-title'
import type { NextPageWithLayout } from '@/types'
-const RedeemCreditsContent = () => {
- const { isLoading: isLoadingProfile } = useProfile()
- const { hasLoaded } = useContext(FeatureFlagContext)
-
- const [selectedOrg, setSelectedOrg] = useState
(null)
-
- const {
- data: organizations,
- error: errorOrganizations,
- isLoading: isLoadingOrganizations,
- isError: isErrorOrganizations,
- } = useOrganizationsQuery()
-
- if (isErrorOrganizations) {
- return (
-
- )
- }
-
- 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.
-
-
-
-
-
-
- {/* [Joshen] Checking for profile as well as organizations query internally waits for profile to be loaded */}
- {isLoadingProfile || isLoadingOrganizations || !hasLoaded ? (
- <>
-
-
- >
- ) : (
- organizations?.map((org) => (
- setSelectedOrg(org.slug)}
- />
- ))
- )}
-
-
- {selectedOrg && (
-
setSelectedOrg(null)}
- />
- )}
-
- )
-}
+const PAGE_TITLE = buildStudioPageTitle({ section: 'Redeem Credits', brand: 'Supabase' })
const RedeemCreditsPage: NextPageWithLayout = () => {
+ const router = useRouter()
+
+ if (!router.isReady) return null
+
return (
-
-
- Code Redemption
-
-
-
-
+ <>
+
+ {PAGE_TITLE}
+
+
+ >
)
}
-RedeemCreditsPage.getLayout = (page) => {page}
-
-export default RedeemCreditsPage
+export default withAuth(RedeemCreditsPage)