Files
supabase/apps/www/components/Pricing/PricingComparisonTable.tsx
Pamela Chia 0bed80b340 chore(telemetry): clean up frontend event catalog (#45964)
## 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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45964)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-18 18:21:58 +08:00

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