polish aws marketplace purchase surfaces

Move the Studio callout into the plan cards grid, quiet Billing to a More information link, and tighten the www ambient mask.
This commit is contained in:
Danny White committed 2026-10-05 12:29:48 +11:00
1 parent 42daedd8cb
commit ec02a89b69
11 files changed
+85 -55

No files matched your search

@@ -1,34 +1,34 @@
import { ExternalLink } from 'lucide-react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { BASE_PATH } from '@/lib/constants'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { MANAGED_BY } from '@/lib/constants/infrastructure'
const AWS_MARKETPLACE_URL = 'https://supabase.com/aws-marketplace'
export function AwsMarketplacePurchaseCallout() {
return (
<section className="flex flex-col gap-3 rounded-md border bg-surface-200 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-surface-100">
<img
alt=""
aria-hidden="true"
src={`${BASE_PATH}/img/icons/aws-icon.svg`}
className="size-5"
/>
</div>
<div className="max-w-2xl">
<h2 className="text-sm font-medium">Purchase through AWS Marketplace</h2>
<p className="text-sm text-foreground-light">
Apply Supabase spend toward an AWS commitment and manage billing through AWS.
</p>
</div>
</div>
<Button asChild className="shrink-0 self-start sm:self-auto" iconRight={<ExternalLink />}>
<a href={AWS_MARKETPLACE_URL} target="_blank" rel="noreferrer">
View purchase options
</a>
</Button>
</section>
<Admonition
type="default"
layout="responsive"
className="w-full"
icon={
<PartnerIcon
organization={{ managed_by: MANAGED_BY.AWS_MARKETPLACE }}
showTooltip={false}
size="medium"
/>
}
title="Prefer purchasing through AWS Marketplace?"
description="Apply Supabase spend toward an AWS commitment and manage billing through AWS."
actions={
<Button asChild variant="default" iconRight={<ExternalLink />}>
<a href={AWS_MARKETPLACE_URL} target="_blank" rel="noreferrer">
View options
</a>
</Button>
}
/>
)
}
@@ -160,7 +160,7 @@ export const InitiateCancellationFlowButton = (props: InitiateCancellationFlowBu
setVisible(true)
}}
>
{props.children ?? 'Cancel Subscription'}
{props.children ?? 'Cancel subscription'}
</ButtonTooltip>
<CancellationFlow
@@ -2,6 +2,7 @@ import { motion } from 'framer-motion'
import { useRouter } from 'next/router'
import { plans as subscriptionsPlans } from 'shared-data/plans'
import { AwsMarketplacePurchaseCallout } from './AwsMarketplacePurchaseCallout'
import { EnterpriseCard } from './EnterpriseCard'
import type { PlanPresentationVariant } from './plan-presentation'
import { PlanCard } from './PlanCard'
@@ -23,6 +24,7 @@ export interface PlanCardsProps {
variant: PlanPresentationVariant
/** Seconds to wait before the cards stagger in. Omit to render them without an entry animation. */
entryDelay?: number
showAwsMarketplacePurchase?: boolean
onSelectTier: (tier: 'tier_free' | 'tier_pro' | 'tier_team') => void
}
@@ -37,6 +39,7 @@ export function PlanCards({
selectedOrganization,
variant,
entryDelay,
showAwsMarketplacePurchase = false,
onSelectTier,
}: PlanCardsProps) {
const router = useRouter()
@@ -45,7 +48,7 @@ export function PlanCards({
const entryAnimation = getPlanCardsEntryAnimation(entryDelay)
return (
<motion.div className="py-6 grid grid-cols-12 gap-3" {...entryAnimation.container}>
<motion.div className="w-full py-6 grid grid-cols-12 gap-3" {...entryAnimation.container}>
{subscriptionsPlans.map((plan) => {
const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1])
const price = planMeta?.price ?? 0
@@ -95,6 +98,11 @@ export function PlanCards({
/>
)
})}
{showAwsMarketplacePurchase && (
<motion.div className="col-span-12" variants={entryAnimation.card}>
<AwsMarketplacePurchaseCallout />
</motion.div>
)}
</motion.div>
)
}
@@ -62,7 +62,7 @@ export function PlanUpdateFullScreenShell({
onClick={onClose}
className="fixed top-4 left-4 z-10"
>
Go back to Studio
Back
</Button>
<div className="fixed top-4 right-4 z-10 flex items-center gap-2">
<Button
@@ -82,12 +82,12 @@ export function PlanUpdateFullScreenShell({
</Button>
</div>
<motion.div
className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-16"
className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-16 items-center"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: contentDelay, duration: 0.3, ease: 'easeOut' }}
>
<h1 className="text-2xl text-center">Change subscription plan for {organizationName}</h1>
<h1 className="text-2xl text-center text-balance max-w-2/3">Change subscription plan for {organizationName}</h1>
{notice}
{children}
</motion.div>
@@ -279,7 +279,7 @@ describe('PlanUpdateSidePanel', () => {
it('renders the full-screen shell instead of the sheet', () => {
render(<PlanUpdateSidePanel />)
expect(screen.getByRole('button', { name: /Go back to Studio/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Back/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Compare plans/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Pricing' })).not.toBeInTheDocument()
})
@@ -296,8 +296,8 @@ describe('PlanUpdateSidePanel', () => {
it('offers AWS Marketplace as a separate purchasing route', () => {
render(<PlanUpdateSidePanel />)
expect(screen.getByText('Purchase through AWS Marketplace')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View purchase options' })).toHaveAttribute(
expect(screen.getByText('Prefer purchasing through AWS Marketplace?')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View options' })).toHaveAttribute(
'href',
'https://supabase.com/aws-marketplace'
)
@@ -331,7 +331,7 @@ describe('PlanUpdateSidePanel', () => {
it('renders gap rows inside the full-screen shell', () => {
render(<PlanUpdateSidePanel />)
expect(screen.getByRole('button', { name: /Go back to Studio/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Back/ })).toBeInTheDocument()
expect(screen.getByText('Daily backups')).toBeInTheDocument()
expect(screen.getByText('1-day log retention')).toBeInTheDocument()
// Free has a `lesser` item → "Plan limits"; Pro has only `missing` items → "Not included"
@@ -352,7 +352,7 @@ describe('PlanUpdateSidePanel', () => {
it('opens neither shell, so the fullscreen variant cannot flash the sheet', () => {
render(<PlanUpdateSidePanel />)
expect(screen.queryByRole('button', { name: /Go back to Studio/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Back/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Upgrade to Pro' })).not.toBeInTheDocument()
})
@@ -377,8 +377,8 @@ describe('PlanUpdateSidePanel', () => {
render(<PlanUpdateSidePanel />)
expect(screen.getByRole('link', { name: 'Pricing' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Go back to Studio/ })).not.toBeInTheDocument()
expect(screen.getByText('Purchase through AWS Marketplace')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Back/ })).not.toBeInTheDocument()
expect(screen.getByText('Prefer purchasing through AWS Marketplace?')).toBeInTheDocument()
})
})
@@ -6,7 +6,6 @@ import { useRouter } from 'next/router'
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { plans as subscriptionsPlans } from 'shared-data/plans'
import { AwsMarketplacePurchaseCallout } from './AwsMarketplacePurchaseCallout'
import { CancellationFlow } from './CancellationFlow'
import {
isFullScreenPresentation,
@@ -268,14 +267,11 @@ export const PlanUpdateSidePanel = () => {
selectedOrganization={selectedOrganization}
variant={presentation}
entryDelay={isFullScreen ? contentDelay : undefined}
showAwsMarketplacePurchase={isPlanPresentationEligible}
onSelectTier={setSelectedTier}
/>
)
const awsMarketplaceCallout = isPlanPresentationEligible ? (
<AwsMarketplacePurchaseCallout />
) : null
return (
<>
{isFullScreen && isPanelOpen && (
@@ -286,7 +282,6 @@ export const PlanUpdateSidePanel = () => {
contentDelay={contentDelay}
onClose={onClose}
>
{awsMarketplaceCallout}
{planCards}
</PlanUpdateFullScreenShell>
)}
@@ -298,7 +293,6 @@ export const PlanUpdateSidePanel = () => {
notice={notice}
onClose={onClose}
>
{awsMarketplaceCallout}
{planCards}
</PlanUpdateSheetShell>
)}
@@ -5,9 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Subscription from './Subscription'
import { render } from '@/tests/helpers'
const { mockSubscription, mockSetPanelKey } = vi.hoisted(() => ({
const { mockSubscription, mockSetPanelKey, mockSelectedOrganization } = vi.hoisted(() => ({
mockSubscription: vi.fn(),
mockSetPanelKey: vi.fn(),
mockSelectedOrganization: vi.fn(),
}))
vi.mock('common', async (importOriginal) => {
@@ -29,7 +30,7 @@ vi.mock('@/hooks/misc/useCheckPermissions', () => ({
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({
data: { managed_by: 'supabase' },
data: mockSelectedOrganization(),
}),
}))
@@ -53,6 +54,7 @@ vi.mock('./PlanUpdateSidePanel', () => ({
describe('Subscription', () => {
beforeEach(() => {
mockSelectedOrganization.mockReturnValue({ managed_by: 'supabase' })
mockSubscription.mockReturnValue({
data: {
plan: { id: 'free', name: 'Free' },
@@ -76,11 +78,15 @@ describe('Subscription', () => {
expect(mockSetPanelKey).toHaveBeenCalledWith('subscriptionPlan')
})
it('shows AWS Marketplace as an alternative purchasing route', () => {
it('links to Compare plans and AWS Marketplace under More information', () => {
render(<Subscription />)
expect(screen.getByText('Purchase through AWS Marketplace')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View purchase options' })).toHaveAttribute(
expect(screen.getByText('More information')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Compare plans/ })).toHaveAttribute(
'href',
'https://supabase.com/pricing#compare-plans'
)
expect(screen.getByRole('link', { name: /AWS Marketplace/ })).toHaveAttribute(
'href',
'https://supabase.com/aws-marketplace'
)
@@ -104,5 +110,7 @@ describe('Subscription', () => {
screen.queryByRole('button', { name: 'Change subscription plan' })
).not.toBeInTheDocument()
expect(screen.getByText('Unable to update plan from Enterprise')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Compare plans/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /AWS Marketplace/ })).not.toBeInTheDocument()
})
})
@@ -1,12 +1,12 @@
import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { Restriction } from '../Restriction'
import { AwsMarketplacePurchaseCallout } from './AwsMarketplacePurchaseCallout'
import { InitiateCancellationFlowButton } from './CancellationFlow'
import { PlanUpdateSidePanel } from './PlanUpdateSidePanel'
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
@@ -23,6 +23,9 @@ import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganizati
import { MANAGED_BY } from '@/lib/constants/infrastructure'
import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings'
const COMPARE_PLANS_URL = 'https://supabase.com/pricing#compare-plans'
const AWS_MARKETPLACE_URL = 'https://supabase.com/aws-marketplace'
const Subscription = () => {
const { slug } = useParams()
const snap = useOrgSettingsPageStateSnapshot()
@@ -66,6 +69,25 @@ const Subscription = () => {
usage quotas.
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-foreground-light m-0">More information</p>
<div className="space-y-1">
<Link href={COMPARE_PLANS_URL} target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 transition hover:opacity-100">
<p className="m-0 text-sm">Compare plans</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
{canPurchaseThroughAws && (
<Link href={AWS_MARKETPLACE_URL} target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 transition hover:opacity-100">
<p className="m-0 text-sm">AWS Marketplace</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
)}
</div>
</div>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
@@ -100,15 +122,13 @@ const Subscription = () => {
</Button>
{currentPlan && currentPlan.id !== 'free' && (
<InitiateCancellationFlowButton variant="danger">
Cancel Subscription
Cancel subscription
</InitiateCancellationFlowButton>
)}
</div>
)}
</div>
{canPurchaseThroughAws && <AwsMarketplacePurchaseCallout />}
{!canChangeTier && (
<div>
{projectUpdateDisabled ? (
@@ -35,7 +35,7 @@ const BackgroundPattern = ({ className, variant = 'center' }: BackgroundPatternP
{variant === 'edges' && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-1/2 w-[min(64rem,62vw)] -translate-x-1/2 bg-alternative [-webkit-mask-image:radial-gradient(ellipse_at_center,black_48%,transparent_78%)] [mask-image:radial-gradient(ellipse_at_center,black_48%,transparent_78%)]"
className="pointer-events-none absolute inset-y-0 left-1/2 w-[min(88rem,84vw)] -translate-x-1/2 bg-alternative [-webkit-mask-image:radial-gradient(ellipse_at_center,black_62%,transparent_88%)] [mask-image:radial-gradient(ellipse_at_center,black_62%,transparent_88%)]"
/>
)}
{/* Soft brand-green radial glow from the top */}
+1 -1
View File
@@ -1436,7 +1436,7 @@ export interface SqlEditorQueryRunButtonClickedEvent {
}
/**
* User clicked on the "Cancel Subscription" Button on the billing settings page.
* User clicked on the "Cancel subscription" Button on the billing settings page.
*
* @group Events
* @source studio
+1 -1
View File
@@ -107,6 +107,6 @@ export const plans: PricingInformation[] = [
priceLabel: '',
priceMonthly: 'Custom',
preface: '',
cta: 'Contact Us',
cta: 'Contact us',
},
] as const