mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): move redeem credits to connect interstitial (#45909)
## What kind of change does this PR introduce? Feature. Part of DEPR-556. ## What is the current behavior? The `/redeem` credit redemption page uses the legacy redeem credits layout and scaffolded two-column UI. It is visually separate from the newer connect interstitial surfaces. ## What is the new behavior? `/redeem` now uses the shared connect interstitial layout with a Supabase logo, signed-in account row, organisation selector, and full-width redeem action. The existing `CreditCodeRedemption` modal still owns the actual code entry, hCaptcha, permission checks, mutation, and success/error handling. Creating a new organisation links to `/new` with a return URL, and the returned organisation is preselected when present. Temporary mock states are available on non-production and Vercel preview environments: - `/redeem?mock=loading` - `/redeem?mock=ready` - `/redeem?mock=redeeming` - `/redeem?mock=redeemed` - `/redeem?mock=already-redeemed` - `/redeem?mock=invalid` - `/redeem?mock=wrong-account` - `/redeem?mock=error` | Before | After | | --- | --- | | <img width="1024" height="759" alt="Redeem Credits Supabase-66AF2C81-89A0-4F95-A591-3550AAD7112A" src="https://github.com/user-attachments/assets/1e0dd7af-3168-4566-a7d9-0b889fe69d4e" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-7F9C59DD-73EC-4717-B2B7-3E6BB325273C" src="https://github.com/user-attachments/assets/60e44213-d2ab-4b5c-b03e-38a3425dbd27" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-A77F2F93-98C3-42E9-B028-049E1AAC0CA3" src="https://github.com/user-attachments/assets/25087b51-94cb-40c7-9dde-1fc957b7dd8e" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-4C4B7818-16A5-430E-B54F-E7FF34229CA0" src="https://github.com/user-attachments/assets/4da11122-b547-4e1e-a953-8d2e4c478431" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-A77F2F93-98C3-42E9-B028-049E1AAC0CA3" src="https://github.com/user-attachments/assets/25087b51-94cb-40c7-9dde-1fc957b7dd8e" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-4C4B7818-16A5-430E-B54F-E7FF34229CA0" src="https://github.com/user-attachments/assets/4da11122-b547-4e1e-a953-8d2e4c478431" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-49FC982C-2605-41B3-8216-B8F270825247" src="https://github.com/user-attachments/assets/8e5fc351-6e11-4fef-9d55-1a60fa34a89a" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-6AF18EF8-1699-4341-A900-34E3AEB59703" src="https://github.com/user-attachments/assets/7adf3d15-73a7-4ef1-9d25-bfcb07b1b15c" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-4F8BBF66-6AF6-403F-B949-079F8E47200B" src="https://github.com/user-attachments/assets/b05461de-1fd3-4251-9a22-9d1a4dea2b20" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-320E56E7-A291-426B-92B4-5CD4518C85B6" src="https://github.com/user-attachments/assets/aa172303-0219-425d-9c8b-07044a9f5cac" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-7E55274C-E39E-443B-B636-63B597E11494" src="https://github.com/user-attachments/assets/4a09d19e-a61c-4c1d-a0a2-e535e53aeee7" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-4D5A1985-D36B-48E1-8108-E95C10C52A12" src="https://github.com/user-attachments/assets/e3baffe9-e0ac-4dff-8f4c-1b01d34fbfe4" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-B1296556-9DDF-48ED-A0DA-4BEFF5D20FBA" src="https://github.com/user-attachments/assets/27ed8201-960b-45e3-a32f-115045d0443e" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-193C4266-29BC-4B03-A746-4C2CE3BD29EB" src="https://github.com/user-attachments/assets/230fb7a0-4049-46b7-84d4-eb549099863b" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-751A900D-A8CF-425C-A745-1511B6DC81E3" src="https://github.com/user-attachments/assets/55ab5719-a77c-486b-b2d0-5926a524a7b4" /> | | <img width="1024" height="759" alt="Redeem Credits Supabase-29305C15-19B7-4015-9041-62B79E0C95A8" src="https://github.com/user-attachments/assets/a1b2ba08-2d1a-4a95-a6ac-4d9e3faf9327" /> | <img width="1024" height="759" alt="Redeem Credits Supabase-A1628FDB-CFBE-47E7-83D7-3D3D426C3C1E" src="https://github.com/user-attachments/assets/7fbbf75e-1f8e-478f-83ec-2ab06dff0545" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added organization selector to the credits redemption workflow. * New "Create Organization" card for quick access to organization creation. * **Refactor** * Streamlined the credits redemption page for improved user experience. * Enhanced organization card display with customizable descriptions. * **Style** * Removed "Coming soon" beta indicators from action cards. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45909?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
9c03ad6b02
commit
e65da2c3e7
8 files changed
+615
-201
No files matched your search
@@ -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 (
|
||||
<section className="space-y-2" aria-label="Organizations">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
|
||||
Organization
|
||||
</p>
|
||||
{description && <p className="text-xs text-foreground-lighter pr-4">{description}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{visibleOrganizations.map((organization) => (
|
||||
<ConnectOrganizationButton
|
||||
key={organization.slug}
|
||||
organization={organization}
|
||||
selected={selectedSlug === organization.slug}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(organization.slug)}
|
||||
description={
|
||||
getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!!createLabel && !!createHrefParams && (
|
||||
<CreateOrganizationCard params={createHrefParams} label={createLabel} />
|
||||
)}
|
||||
|
||||
{hasOverflow && (
|
||||
<Collapsible open={showMore} onOpenChange={setShowMore}>
|
||||
<CollapsibleTrigger className={CONNECT_DISCLOSURE_TRIGGER_CLASSNAME}>
|
||||
<span>{showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`}</span>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', showMore && 'rotate-180')}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
|
||||
<div className="space-y-2 pt-1">
|
||||
{overflowOrganizations.map((organization) => (
|
||||
<ConnectOrganizationButton
|
||||
key={organization.slug}
|
||||
organization={organization}
|
||||
selected={selectedSlug === organization.slug}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(organization.slug)}
|
||||
description={
|
||||
getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'group relative block w-full cursor-pointer text-left disabled:cursor-not-allowed disabled:opacity-50',
|
||||
disabled && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<OrganizationCard
|
||||
isLink={false}
|
||||
organization={organization}
|
||||
description={description}
|
||||
className={cn(
|
||||
'pointer-events-none shadow-none transition-colors',
|
||||
!disabled && !selected && 'group-hover:border-default group-hover:bg-surface-200',
|
||||
selected &&
|
||||
'border-brand bg-brand-200/20 dark:bg-brand-300 pr-10 group-hover:border-brand group-hover:bg-brand-200/20'
|
||||
)}
|
||||
/>
|
||||
{selected && (
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full bg-brand-500 dark:bg-brand-200 text-white dark:text-brand">
|
||||
<Check className="size-3.5" strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -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={
|
||||
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span>{organization.plan.name} Plan</span>
|
||||
{numProjects > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>
|
||||
{numProjects} project{numProjects > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
shouldRenderDefaultDescription ? (
|
||||
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span>{organization.plan.name} Plan</span>
|
||||
{numProjects > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>
|
||||
{numProjects} project{numProjects > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PartnerIcon organization={organization} />
|
||||
{isMfaRequired && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-default">
|
||||
<Lock size={12} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={!isUserMFAEnabled ? 'w-80' : ''}>
|
||||
MFA enforced
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PartnerIcon organization={organization} />
|
||||
{isMfaRequired && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-default">
|
||||
<Lock size={12} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={!isUserMFAEnabled ? 'w-80' : ''}>
|
||||
MFA enforced
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -78,3 +85,27 @@ export const OrganizationCard = ({
|
||||
return <Fragment>{renderContent()}</Fragment>
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link href={createOrganizationHref}>
|
||||
<ActionCard
|
||||
bgColor="bg border"
|
||||
className={cn(
|
||||
'flex items-center min-h-[70px] [&>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={<Plus size={18} strokeWidth={1} className="text-foreground" />}
|
||||
title={label}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div data-testid="credit-redemption">
|
||||
Credit redemption for {props.slug} with code {creditRedemptionQueryCode.current}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
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(
|
||||
<FeatureFlagContext.Provider value={{ configcat: {}, posthog: {}, hasLoaded: true }}>
|
||||
<RedeemCreditsScreen />
|
||||
</FeatureFlagContext.Provider>,
|
||||
{ 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}) => (
|
||||
<InterstitialLayout logo={<SupabaseLogo />} title={title} description={description}>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</InterstitialLayout>
|
||||
)
|
||||
|
||||
export const RedeemCreditsScreen = () => {
|
||||
const router = useRouter()
|
||||
const { profile, isLoading: isLoadingProfile } = useProfile()
|
||||
const { hasLoaded } = useContext(FeatureFlagContext)
|
||||
|
||||
const [selectedOrgSlug, setSelectedOrgSlug] = useState<string | null>(null)
|
||||
const [redemptionModalOrgSlug, setRedemptionModalOrgSlug] = useState<string | null>(null)
|
||||
const appliedReturnSelectedOrgRef = useRef<string | null>(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 (
|
||||
<RedeemCreditsInterstitial
|
||||
title={<ShimmeringLoader className="mx-auto h-7 w-32 max-w-full py-0" />}
|
||||
description={<ShimmeringLoader className="mx-auto h-4 w-56 max-w-full py-0" />}
|
||||
>
|
||||
<ConnectLoadingCards />
|
||||
</RedeemCreditsInterstitial>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOrganizationsError) {
|
||||
return (
|
||||
<RedeemCreditsInterstitial
|
||||
title="Unable to load credit redemption"
|
||||
description="Please try again before redeeming this code"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Admonition
|
||||
type="warning"
|
||||
description={
|
||||
<>
|
||||
We could not load your organizations.
|
||||
{organizationsError && (
|
||||
<span className="mt-1 block text-foreground-lighter">
|
||||
Error: {organizationsError.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</RedeemCreditsInterstitial>
|
||||
)
|
||||
}
|
||||
|
||||
const createOrganizationParams = {
|
||||
returnTo: router.asPath || '/redeem',
|
||||
returnToOrgParam: RETURN_TO_SELECTED_ORG_PARAM,
|
||||
}
|
||||
|
||||
const openRedemption = () => {
|
||||
if (!selectedOrgSlug) return
|
||||
setRedemptionModalOrgSlug(selectedOrgSlug)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RedeemCreditsInterstitial
|
||||
title="Redeem credits"
|
||||
description="Choose an organization to redeem this code"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<InterstitialAccountRow displayName={displayName} />
|
||||
|
||||
<OrganizationSelector
|
||||
organizations={organizationOptions}
|
||||
selectedSlug={selectedOrgSlug}
|
||||
onSelect={setSelectedOrgSlug}
|
||||
getOrganizationDescription={(organization) => `${organization.plan.name} Plan`}
|
||||
createLabel={
|
||||
organizationOptions.length === 0
|
||||
? 'Create your first organization'
|
||||
: 'Create new organization'
|
||||
}
|
||||
createHrefParams={createOrganizationParams}
|
||||
/>
|
||||
|
||||
{organizationOptions.length === 0 && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
description="Create an organization before redeeming this credit code."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
disabled={!selectedOrgSlug || organizationOptions.length === 0}
|
||||
onClick={openRedemption}
|
||||
>
|
||||
Redeem credits
|
||||
</Button>
|
||||
<p className="text-center text-xs text-foreground-lighter text-balance">
|
||||
Credits apply to one organization and are used toward future invoices before your
|
||||
payment method is charged.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RedeemCreditsInterstitial>
|
||||
|
||||
{redemptionModalOrgSlug && (
|
||||
<CreditCodeRedemption
|
||||
modalVisible
|
||||
slug={redemptionModalOrgSlug}
|
||||
onClose={() => setRedemptionModalOrgSlug(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectLoadingCards = () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
|
||||
<ShimmeringLoader className="size-8 flex-shrink-0 rounded-full py-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<ShimmeringLoader className="h-3 w-20 py-0" />
|
||||
<ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="space-y-2" aria-label="Organizations">
|
||||
<ShimmeringLoader className="h-3 w-24 py-0" />
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="shadow-none">
|
||||
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
|
||||
<ShimmeringLoader className="size-9 flex-shrink-0 rounded-lg py-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<ShimmeringLoader className="h-4 w-32 py-0" />
|
||||
<ShimmeringLoader className="h-3 w-20 py-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ShimmeringLoader className="h-10 w-full py-0" />
|
||||
<ShimmeringLoader className="h-10 w-full py-0" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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<RedeemCreditsLayoutProps>) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { appTitle } = useCustomContent(['app:title'])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Redeem Credits | {appTitle || 'Supabase'}</title>
|
||||
</Head>
|
||||
<main className="flex flex-col 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 grow mx-auto w-[90vw] space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(RedeemCreditsLayout)
|
||||
@@ -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}
|
||||
>
|
||||
<div className={`relative flex items-start gap-3`}>
|
||||
{card.isBeta && (
|
||||
<Badge className="absolute -right-5 -top-5 bg-surface-300 text-xs text-foreground">
|
||||
Coming soon
|
||||
</Badge>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-full ${card.bgColor} w-8 h-8 flex items-center justify-center shrink-0`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className={`relative flex items-start gap-3`}>
|
||||
{card.isBeta && (
|
||||
<Badge className="absolute -right-5 -top-5 bg-surface-300 text-xs text-foreground">
|
||||
Coming soon
|
||||
</Badge>
|
||||
)}
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div
|
||||
className={`rounded-full ${card.bgColor} w-8 h-8 flex items-center justify-center shrink-0`}
|
||||
>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="grow flex flex-col gap-0 min-w-0">
|
||||
<h3 title={card.title} className="text-sm text-foreground mb-0 truncate max-w-full">
|
||||
{card.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 title={card.title} className="text-sm text-foreground mb-0 truncate max-w-full">
|
||||
{card.title}
|
||||
</h3>
|
||||
</div>
|
||||
{typeof card.description === 'string' ? (
|
||||
<pre className="text-xs text-foreground-light font-sans">{card.description}</pre>
|
||||
) : (
|
||||
|
||||
+17
-102
@@ -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<string | null>(null)
|
||||
|
||||
const {
|
||||
data: organizations,
|
||||
error: errorOrganizations,
|
||||
isLoading: isLoadingOrganizations,
|
||||
isError: isErrorOrganizations,
|
||||
} = useOrganizationsQuery()
|
||||
|
||||
if (isErrorOrganizations) {
|
||||
return (
|
||||
<AlertError
|
||||
className="mt-4"
|
||||
error={errorOrganizations}
|
||||
subject="Failed to retrieve organizations"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 pt-10 gap-8">
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p>To redeem your credits, select one of your organizations.</p>
|
||||
<p className="text-sm text-foreground-light text-balance">
|
||||
The credits will be applied to that organization only and cannot be transferred or
|
||||
shared between organizations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>Want to start fresh?</p>
|
||||
<p className="mt-1 text-sm text-foreground-light text-balance">
|
||||
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 w-min" size="tiny" type="primary">
|
||||
<Link href="/new">Create organization</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{/* [Joshen] Checking for profile as well as organizations query internally waits for profile to be loaded */}
|
||||
{isLoadingProfile || isLoadingOrganizations || !hasLoaded ? (
|
||||
<>
|
||||
<ShimmeringLoader className="w-full h-[70px]" />
|
||||
<ShimmeringLoader className="w-full h-[70px]" />
|
||||
</>
|
||||
) : (
|
||||
organizations?.map((org) => (
|
||||
<OrganizationCard
|
||||
key={org.id}
|
||||
isLink={false}
|
||||
organization={org}
|
||||
onClick={() => setSelectedOrg(org.slug)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedOrg && (
|
||||
<CreditCodeRedemption
|
||||
modalVisible
|
||||
slug={selectedOrg}
|
||||
onClose={() => setSelectedOrg(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const PAGE_TITLE = buildStudioPageTitle({ section: 'Redeem Credits', brand: 'Supabase' })
|
||||
|
||||
const RedeemCreditsPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
|
||||
if (!router.isReady) return null
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldHeader>
|
||||
<ScaffoldTitle>Code Redemption</ScaffoldTitle>
|
||||
</ScaffoldHeader>
|
||||
<ScaffoldDivider />
|
||||
<RedeemCreditsContent />
|
||||
</ScaffoldContainer>
|
||||
<>
|
||||
<Head>
|
||||
<title>{PAGE_TITLE}</title>
|
||||
</Head>
|
||||
<RedeemCreditsScreen />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
RedeemCreditsPage.getLayout = (page) => <RedeemCreditsLayout>{page}</RedeemCreditsLayout>
|
||||
|
||||
export default RedeemCreditsPage
|
||||
export default withAuth(RedeemCreditsPage)
|
||||
Reference in new issue
Block a user