feat: add Free subscription tier support (#8864)

## Summary

Add frontend support for a Free subscription tier — login/signup page
restructuring, telemetry instrumentation, and tier-aware billing gating.

## Changes

- **What**: 
- Restructure login/signup pages: OAuth buttons promoted as primary
sign-in method, email login available via progressive disclosure
- Add Free tier badge on Google sign-up button with dynamic credit count
from remote config
- Add `FREE` subscription tier to type system (tier pricing, tier rank,
registry types)
  - Add `isFreeTier` computed to `useSubscription()`
- Disable credit top-up for Free tier users (dialogService,
purchaseCredits, popover CTA)
- Show subscription/upgrade dialog instead of top-up dialog when Free
tier user hits out-of-credits
- Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened`
with `free_tier_badge_shown`, track email toggle clicks

## Review Focus

- Tier gating logic: Free tier users should see "Upgrade" instead of
"Add Credits" and never reach the top-up flow
- Telemetry event design for Mixpanel funnel analysis
- Progressive disclosure UX on login/signup pages

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03)
by [Unito](https://www.unito.io)
This commit is contained in:
Hunter
2026-02-24 23:28:51 -05:00
committed by GitHub
parent aee207f16c
commit 8c3738fb77
39 changed files with 720 additions and 221 deletions

View File

@@ -144,6 +144,7 @@
<!-- Active state: show Manage Payment, Upgrade, and menu -->
<template v-else>
<Button
v-if="!isFreeTierPlan"
size="lg"
variant="secondary"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@@ -155,11 +156,12 @@
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
@click="handleUpgrade"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-if="!isFreeTierPlan"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
@@ -366,9 +368,11 @@ import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
@@ -385,6 +389,7 @@ const isSettingUp = computed(() => billingOperationStore.isSettingUp)
const {
isActiveSubscription,
isFreeTier: isFreeTierPlan,
subscription,
showSubscriptionDialog,
manageSubscription,
@@ -394,6 +399,7 @@ const {
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
const isResubscribing = ref(false)
@@ -454,6 +460,10 @@ const showZeroState = computed(
function handleSubscribeWorkspace() {
showSubscriptionDialog()
}
function handleUpgrade() {
isFreeTierPlan.value ? showPricingTable() : showSubscriptionDialog()
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
@@ -534,6 +544,7 @@ const creditsRemainingLabel = computed(() =>
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
@@ -542,21 +553,9 @@ const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature' | 'icon'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
icon?: string
}
const tierBenefits = computed((): Benefit[] => {
const tierBenefits = computed((): TierBenefit[] => {
const key = tierKey.value
const benefits: Benefit[] = []
const benefits: TierBenefit[] = []
if (!isInPersonalWorkspace.value) {
benefits.push({
@@ -567,33 +566,7 @@ const tierBenefits = computed((): Benefit[] => {
})
}
benefits.push(
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
)
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
benefits.push(...getCommonTierBenefits(key, t, n))
return benefits
})