mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
## Summary Resolves 13 findings (2 HIGH, 5 MEDIUM, 6 LOW) from the frontend telemetry audit: 1 action-string collision, 1 camelCase experiment name, 9 dead events removed, 4 missing org groups attached, 1 ambiguous property renamed, 1 raw-string property narrowed, plus consolidations and a structural tightening on TABLE_EVENT_ACTIONS. ## Changes ### HIGH - Rename `EventPageCtaClickedEvent.action` to `www_event_page_cta_clicked` so it no longer collides with the pricing CTA event (which had a different schema sharing the same action string) - Snake_case the header-upgrade experiment exposure name (`headerUpgradeCta_experiment_exposed` → `header_upgrade_cta_experiment_exposed`); PostHog flag key and `?source=` URL param unchanged ### MEDIUM - Remove 4 dead `ProjectCreation*Step*` events (referenced a v2 route that doesn't exist; 0 emissions) - Remove 4 dead experiment exposure events: `ProjectCreationRlsOptionExperimentExposed`, `HomeNewExperimentExposed`, `TableCreateGeneratePoliciesExperimentExposed`, `TableCreateGeneratePoliciesExperimentConverted` (0 emissions) - Attach org group to `dpa_request_button_clicked` (0% had `$group_0` per Hex) - Delete `RegisterStateOfStartups2025NewsletterClicked` (interface naming outlier, 0 emissions, page renamed to 2026) - Rename `AssistantSuggestionRunQueryClickedEvent.category` to `mutationType` with tightened literal union (`'functions' | 'rls-policies' | 'unknown'`) - Attach org group to `project_creation_default_privileges_exposed` on Vercel surface via explicit `groupOverrides` (auto-injection misses because `useSelectedOrganizationQuery` is undefined on that page) ### LOW - Consolidate `IndexAdvisorBannerEnableButtonClickedEvent` + `IndexAdvisorDialogEnableButtonClickedEvent` into one event with `origin: 'banner' | 'dialog'` - Rename `ImportDataFileDroppedEvent` → `ImportDataFileAddedEvent` so the interface name matches the action and the verb is on the approved list - Rename `LogDrainConfirmButtonSubmittedEvent` → `LogDrainRemovedEvent` and action to `log_drain_removed` (fires on delete-confirm modal, matches `CronJobRemovedEvent` pattern) - Add `type` property to `CronJobRemovedEvent` (parsed from the job's command), matching the create/update event shape - Tighten `TABLE_EVENT_ACTIONS` values with `satisfies` against the event union so renames in the union fail typecheck here too - Attach org group to `www_pricing_plan_cta_clicked` at 5 emission sites when an org is available in the page context - Narrow `unified_logs_row_clicked.logType` from raw `string` to the 5-literal `LOG_TYPES` union (zod already validates server values) ### Bundled refactor Migrated 5 emission sites from deprecated `useSendEventMutation` to `useTrack` while their containing files were being edited: `DPA.tsx`, `DisplayBlockRenderer.tsx`, `Grid.tsx` (2 events), `DeleteCronJob.tsx`. Full sweep of the remaining ~79 files is a separate follow-up. ## Testing Mostly just renaming of events ## Linear - fixes GROWTH-798 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Standardized telemetry to a unified tracking system for more consistent analytics. * Simplified experiment exposure reporting for upgrade prompts. * **New Features** * More granular tracking for CSV import, cron job deletions, log drain removals, DPA downloads/requests, and pricing CTAs. * Assistant now classifies mutation queries more precisely. * **Bug Fixes** * Improved default-privileges exposure logic on Vercel deployments (skips when org missing). <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45964) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { PricingTableRowDesktop, PricingTableRowMobile } from '~/components/Pricing/PricingTableRow'
|
|
import Solutions from '~/data/MainProducts'
|
|
import { Organization } from '~/data/organizations'
|
|
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
|
import Link from 'next/link'
|
|
import { useState } from 'react'
|
|
import { plans } from 'shared-data/plans'
|
|
import { pricing } from 'shared-data/pricing'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from 'ui'
|
|
|
|
import UpgradePlan from './UpgradePlan'
|
|
|
|
const MobileHeader = ({
|
|
description,
|
|
priceDescription,
|
|
price,
|
|
plan,
|
|
showDollarSign = true,
|
|
from = false,
|
|
organizations,
|
|
hasExistingOrganizations,
|
|
}: {
|
|
description: string
|
|
priceDescription: string
|
|
price: string
|
|
plan: string
|
|
showDollarSign?: boolean
|
|
from?: boolean
|
|
organizations?: Organization[]
|
|
hasExistingOrganizations?: boolean
|
|
}) => {
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
const orgSlug = organizations?.[0]?.slug
|
|
|
|
const selectedPlan = plans.find((p) => p.name === plan)!
|
|
const isUpgradablePlan = selectedPlan.name === 'Pro' || selectedPlan.name === 'Team'
|
|
|
|
return (
|
|
<div className="mt-8 px-4 mobile-header">
|
|
<h2 className="text-foreground text-3xl font-medium uppercase font-mono">{plan}</h2>
|
|
<div className="flex items-baseline gap-2">
|
|
{from && <span className="text-foreground text-base">From</span>}
|
|
{showDollarSign ? (
|
|
<span className="h1 font-mono">
|
|
{plan !== 'Enterprise' ? '$' : ''}
|
|
{price}
|
|
</span>
|
|
) : (
|
|
<span className="text-foreground-light">{price}</span>
|
|
)}
|
|
|
|
<p className="p">{priceDescription}</p>
|
|
</div>
|
|
<p className="p">{description}</p>
|
|
{isUpgradablePlan && hasExistingOrganizations ? (
|
|
<UpgradePlan
|
|
organizations={organizations}
|
|
onClick={() =>
|
|
sendTelemetryEvent({
|
|
action: 'www_pricing_plan_cta_clicked',
|
|
properties: {
|
|
plan,
|
|
showUpgradeText: true,
|
|
section: 'comparison_table',
|
|
tableMode: 'mobile',
|
|
},
|
|
...(orgSlug && { groups: { organization: orgSlug } }),
|
|
})
|
|
}
|
|
size="medium"
|
|
planId={selectedPlan.planId}
|
|
/>
|
|
) : (
|
|
<Button asChild size="medium" type={plan === 'Enterprise' ? 'default' : 'primary'} block>
|
|
<Link
|
|
href={selectedPlan.href}
|
|
onClick={() =>
|
|
sendTelemetryEvent({
|
|
action: 'www_pricing_plan_cta_clicked',
|
|
properties: {
|
|
plan,
|
|
showUpgradeText: false,
|
|
section: 'comparison_table',
|
|
tableMode: 'mobile',
|
|
},
|
|
...(orgSlug && { groups: { organization: orgSlug } }),
|
|
})
|
|
}
|
|
>
|
|
{selectedPlan.cta}
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface PricingComparisonTableProps {
|
|
organizations?: Organization[]
|
|
hasExistingOrganizations?: boolean
|
|
}
|
|
|
|
const PricingComparisonTable = ({
|
|
organizations,
|
|
hasExistingOrganizations,
|
|
}: PricingComparisonTableProps) => {
|
|
const [activeMobilePlan, setActiveMobilePlan] = useState('Free')
|
|
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
const orgSlug = organizations?.[0]?.slug
|
|
|
|
return (
|
|
<div
|
|
id="compare-plans"
|
|
className="sm:pb-18 container relative mx-auto px-4 pb-16 md:pb-16 lg:px-16 xl:px-20"
|
|
>
|
|
{/* <!-- xs to lg --> */}
|
|
<div className="lg:hidden">
|
|
{/* Free - Mobile */}
|
|
<div className="bg-background p-2 sticky top-14 z-10 pt-4">
|
|
<div className="bg-surface-100 rounded-lg border py-2 px-4 flex justify-between items-center">
|
|
<label className="text-foreground-lighter grow">Change plan</label>
|
|
<Select
|
|
name="Change plan"
|
|
value={activeMobilePlan}
|
|
onValueChange={(value) => setActiveMobilePlan(value)}
|
|
>
|
|
<SelectTrigger id="change-plan" className="w-auto min-w-[120px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="Free">Free</SelectItem>
|
|
<SelectItem value="Pro">Pro</SelectItem>
|
|
<SelectItem value="Team">Team</SelectItem>
|
|
<SelectItem value="Enterprise">Enterprise</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{activeMobilePlan === 'Free' && (
|
|
<>
|
|
<MobileHeader
|
|
plan="Free"
|
|
price={'0'}
|
|
priceDescription={'/month'}
|
|
description={'Perfect for hobby projects and experiments'}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.database}
|
|
plan={'free'}
|
|
icon={Solutions['database'].icon}
|
|
sectionId="database"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.auth}
|
|
plan={'free'}
|
|
icon={Solutions['authentication'].icon}
|
|
sectionId="auth"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.storage}
|
|
plan={'free'}
|
|
icon={Solutions['storage'].icon}
|
|
sectionId="storage"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.realtime}
|
|
plan={'free'}
|
|
icon={Solutions['realtime'].icon}
|
|
sectionId="realtime"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing['edge_functions']}
|
|
plan={'free'}
|
|
icon={Solutions['functions'].icon}
|
|
sectionId="edge-functions"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.dashboard}
|
|
plan={'free'}
|
|
icon={pricing.dashboard.icon}
|
|
sectionId="dashboard"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.security}
|
|
plan={'free'}
|
|
icon={pricing.security.icon}
|
|
sectionId="security"
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.support}
|
|
plan={'free'}
|
|
icon={pricing.support.icon}
|
|
sectionId="support"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeMobilePlan === 'Pro' && (
|
|
<>
|
|
<MobileHeader
|
|
plan="Pro"
|
|
from={false}
|
|
price={'25'}
|
|
priceDescription={'/month + additional use'}
|
|
description={'Everything you need to scale your project into production'}
|
|
organizations={organizations}
|
|
hasExistingOrganizations={hasExistingOrganizations}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.database}
|
|
plan={'pro'}
|
|
icon={Solutions['database'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.auth}
|
|
plan={'pro'}
|
|
icon={Solutions['authentication'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.storage}
|
|
plan={'pro'}
|
|
icon={Solutions['storage'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.realtime}
|
|
plan={'pro'}
|
|
icon={Solutions['realtime'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing['edge_functions']}
|
|
plan={'pro'}
|
|
icon={Solutions['functions'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.dashboard}
|
|
plan={'pro'}
|
|
icon={pricing.dashboard.icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.security}
|
|
plan={'pro'}
|
|
icon={pricing.security.icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.support}
|
|
plan={'pro'}
|
|
icon={pricing.support.icon}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeMobilePlan === 'Team' && (
|
|
<>
|
|
<MobileHeader
|
|
plan="Team"
|
|
from={false}
|
|
price={'599'}
|
|
priceDescription={'/month + additional use'}
|
|
description={'Collaborate with different permissions and access patterns'}
|
|
organizations={organizations}
|
|
hasExistingOrganizations={hasExistingOrganizations}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.database}
|
|
plan={'team'}
|
|
icon={Solutions['database'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.auth}
|
|
plan={'team'}
|
|
icon={Solutions['authentication'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.storage}
|
|
plan={'team'}
|
|
icon={Solutions['storage'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.realtime}
|
|
plan={'team'}
|
|
icon={Solutions['realtime'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing['edge_functions']}
|
|
plan={'team'}
|
|
icon={Solutions['functions'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.dashboard}
|
|
plan={'team'}
|
|
icon={pricing.dashboard.icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.security}
|
|
plan={'team'}
|
|
icon={pricing.security.icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.support}
|
|
plan={'team'}
|
|
icon={pricing.support.icon}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeMobilePlan === 'Enterprise' && (
|
|
<>
|
|
<MobileHeader
|
|
plan="Enterprise"
|
|
price={'Contact us for a quote'}
|
|
priceDescription={''}
|
|
description={'Designated support team, account manager and technical specialist'}
|
|
showDollarSign={false}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.database}
|
|
plan={'enterprise'}
|
|
icon={Solutions['database'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.auth}
|
|
plan={'enterprise'}
|
|
icon={Solutions['authentication'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.storage}
|
|
plan={'enterprise'}
|
|
icon={Solutions['storage'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.realtime}
|
|
plan={'enterprise'}
|
|
icon={Solutions['realtime'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing['edge_functions']}
|
|
plan={'enterprise'}
|
|
icon={Solutions['functions'].icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.dashboard}
|
|
plan={'enterprise'}
|
|
icon={pricing.dashboard.icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.security}
|
|
plan={'enterprise'}
|
|
icon={pricing.security.icon}
|
|
/>
|
|
<PricingTableRowMobile
|
|
category={pricing.support}
|
|
plan={'enterprise'}
|
|
icon={pricing.support.icon}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* <!-- lg+ --> */}
|
|
<div className="hidden lg:block">
|
|
<table className="h-px w-full table-fixed">
|
|
<caption className="sr-only">Pricing plan comparison</caption>
|
|
<thead className="bg-background sticky top-[62px] z-10">
|
|
<tr>
|
|
<th
|
|
className="text-foreground w-1/3 px-6 pt-2 pb-2 text-left text-sm font-normal"
|
|
scope="col"
|
|
>
|
|
<span className="sr-only">Feature by</span>
|
|
<span
|
|
className="h-0.25 absolute bottom-0 left-0 w-full"
|
|
style={{ height: '1px' }}
|
|
/>
|
|
</th>
|
|
|
|
{plans.map((plan) => {
|
|
const isUpgradablePlan = plan.name === 'Pro' || plan.name === 'Team'
|
|
|
|
return (
|
|
<th
|
|
className="text-foreground w-1/4 px-0 text-left text-sm font-normal"
|
|
scope="col"
|
|
key={plan.name}
|
|
>
|
|
<span className="flex flex-col px-6 pr-2 pt-2 gap-1.5">
|
|
<span className="flex flex-col xl:flex-row xl:items-end gap-1">
|
|
<h3 className="text-lg xl:text-xl 2xl:text-2xl leading-5 uppercase font-mono font-normal flex items-center">
|
|
{plan.name}
|
|
</h3>
|
|
<p
|
|
className={cn(
|
|
'text-foreground-lighter -my-1 xl:m-0',
|
|
plan.name === 'Enterprise' && 'xl:opacity-0'
|
|
)}
|
|
>
|
|
<span
|
|
className="text-foreground-lighter font-mono text-xl mr-1 tracking-tighter"
|
|
translate="no"
|
|
>
|
|
{plan.name !== 'Enterprise' && '$'}
|
|
{plan.priceMonthly}
|
|
</span>
|
|
{['Free', 'Pro', 'Team'].includes(plan.name) && (
|
|
<span className="text-[13px] leading-4 mt-1">{plan.costUnit}</span>
|
|
)}
|
|
</p>
|
|
</span>
|
|
<span className="flex flex-col justify-between h-full pb-2">
|
|
{isUpgradablePlan && hasExistingOrganizations ? (
|
|
<UpgradePlan
|
|
organizations={organizations}
|
|
onClick={() =>
|
|
sendTelemetryEvent({
|
|
action: 'www_pricing_plan_cta_clicked',
|
|
properties: {
|
|
plan: plan.name,
|
|
showUpgradeText: true,
|
|
section: 'comparison_table',
|
|
tableMode: 'desktop',
|
|
},
|
|
...(orgSlug && { groups: { organization: orgSlug } }),
|
|
})
|
|
}
|
|
size="tiny"
|
|
planId={plan.planId}
|
|
/>
|
|
) : (
|
|
<Button
|
|
asChild
|
|
size="tiny"
|
|
type={plan.name === 'Enterprise' ? 'default' : 'primary'}
|
|
block
|
|
>
|
|
<Link
|
|
href={plan.href}
|
|
onClick={() =>
|
|
sendTelemetryEvent({
|
|
action: 'www_pricing_plan_cta_clicked',
|
|
properties: {
|
|
plan: plan.name,
|
|
showUpgradeText: false,
|
|
section: 'comparison_table',
|
|
tableMode: 'desktop',
|
|
},
|
|
...(orgSlug && { groups: { organization: orgSlug } }),
|
|
})
|
|
}
|
|
>
|
|
{plan.cta}
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</th>
|
|
)
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="border-default divide-border divide-y first:divide-y-0">
|
|
<PricingTableRowDesktop
|
|
category={pricing.database}
|
|
icon={Solutions['database'].icon}
|
|
sectionId="database"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing.auth}
|
|
icon={Solutions['authentication'].icon}
|
|
sectionId="auth"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing.storage}
|
|
icon={Solutions['storage'].icon}
|
|
sectionId="storage"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing.realtime}
|
|
icon={Solutions['realtime'].icon}
|
|
sectionId="realtime"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing['edge_functions']}
|
|
icon={Solutions['functions'].icon}
|
|
sectionId="edge-functions"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing.dashboard}
|
|
icon={pricing.dashboard.icon}
|
|
sectionId="dashboard"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing.security}
|
|
icon={pricing.security.icon}
|
|
sectionId="security"
|
|
/>
|
|
<PricingTableRowDesktop
|
|
category={pricing.support}
|
|
icon={pricing.support.icon}
|
|
sectionId="support"
|
|
/>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PricingComparisonTable
|