mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
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:
1 parent
42daedd8cb
commit
ec02a89b69
11 files changed
+85
-55
No files matched your search
+24
-24
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -160,7 +160,7 @@ export const InitiateCancellationFlowButton = (props: InitiateCancellationFlowBu
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
{props.children ?? 'Cancel Subscription'}
|
||||
{props.children ?? 'Cancel subscription'}
|
||||
</ButtonTooltip>
|
||||
|
||||
<CancellationFlow
|
||||
|
||||
+9
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+7
-7
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
-7
@@ -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>
|
||||
)}
|
||||
|
||||
+13
-5
@@ -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()
|
||||
})
|
||||
})
|
||||
+24
-4
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,6 +107,6 @@ export const plans: PricingInformation[] = [
|
||||
priceLabel: '',
|
||||
priceMonthly: 'Custom',
|
||||
preface: '',
|
||||
cta: 'Contact Us',
|
||||
cta: 'Contact us',
|
||||
},
|
||||
] as const
|
||||
Reference in new issue
Block a user