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'} - -
-
-
-
-
-
- Supabase - Supabase Logo -
-
-
-
-
- -
- {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)