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

@@ -2,14 +2,30 @@
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mt-6 mb-8 flex flex-col gap-4">
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.login.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<i18n-t
v-if="isFreeTierEnabled"
keypath="auth.login.signUpFreeTierPromo"
tag="p"
class="my-0 text-base text-muted"
:plural="freeTierCredits ?? undefined"
>
<template #signUp>
<span
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</template>
<template #credits>{{ freeTierCredits }}</template>
</i18n-t>
<p v-else class="my-0 text-base text-muted">
{{ t('auth.login.newUser') }}
<span
class="ml-1 cursor-pointer text-blue-500"
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
@@ -20,36 +36,49 @@
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<template v-if="!showEmailForm">
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<template v-else>
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</div>
</template>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
@@ -75,7 +104,6 @@
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -84,6 +112,7 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
@@ -95,6 +124,16 @@ const authActions = useFirebaseAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
function switchToEmailForm() {
showEmailForm.value = true
}
function switchToSocialLogin() {
showEmailForm.value = false
}
const navigateToSignup = async () => {
await router.push({ name: 'cloud-signup', query: route.query })

View File

@@ -22,42 +22,77 @@
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<template v-if="!showEmailForm">
<p v-if="isFreeTierEnabled" class="mb-4 text-sm text-muted-foreground">
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
credits: freeTierCredits
})
: t('auth.login.freeTierDescriptionGeneric')
}}
</p>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div class="relative">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<span
v-if="isFreeTierEnabled"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-[10px] font-bold whitespace-nowrap text-gray-900"
>
{{ t('auth.login.freeTierBadge') }}
</span>
</div>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</div>
</template>
<!-- Terms & Contact -->
<div class="mt-5 text-sm text-gray-600">
<p class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
@@ -68,30 +103,29 @@
</a>
{{ t('auth.login.andText') }}
<a
href="/privacy-policy"
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
<p class="mt-2">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</p>
<p class="mt-2 text-sm text-gray-600">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -100,6 +134,7 @@ import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -115,6 +150,14 @@ const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()
const {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
} = useFreeTierOnboarding()
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
@@ -161,7 +204,7 @@ const signUpWithEmail = async (values: SignUpData) => {
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
useTelemetry()?.trackSignupOpened()
telemetry?.trackSignupOpened()
}
userIsInChina.value = await isInChina()

View File

@@ -27,6 +27,7 @@ const selectedTierKey = ref<TierKey | null>(null)
const tierDisplayName = computed(() => {
if (!selectedTierKey.value) return ''
const names: Record<TierKey, string> = {
free: t('subscription.tiers.free.name'),
standard: t('subscription.tiers.standard.name'),
creator: t('subscription.tiers.creator.name'),
pro: t('subscription.tiers.pro.name'),
@@ -58,6 +59,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
// Only paid tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
await router.push('/')

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
const mockRemoteConfig = vi.hoisted(() => ({
value: { free_tier_credits: 50 } as Record<string, unknown>
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
describe('useFreeTierOnboarding', () => {
describe('showEmailForm', () => {
it('starts as false', () => {
const { showEmailForm } = useFreeTierOnboarding()
expect(showEmailForm.value).toBe(false)
})
it('switchToEmailForm sets it to true', () => {
const { showEmailForm, switchToEmailForm } = useFreeTierOnboarding()
switchToEmailForm()
expect(showEmailForm.value).toBe(true)
})
it('switchToSocialLogin sets it back to false', () => {
const { showEmailForm, switchToEmailForm, switchToSocialLogin } =
useFreeTierOnboarding()
switchToEmailForm()
switchToSocialLogin()
expect(showEmailForm.value).toBe(false)
})
})
describe('freeTierCredits', () => {
it('returns value from remote config', () => {
const { freeTierCredits } = useFreeTierOnboarding()
expect(freeTierCredits.value).toBe(50)
})
})
describe('isFreeTierEnabled', () => {
it('returns true when remote config says enabled', () => {
mockRemoteConfig.value.new_free_tier_subscriptions = true
const { isFreeTierEnabled } = useFreeTierOnboarding()
expect(isFreeTierEnabled.value).toBe(true)
})
it('returns false when remote config says disabled', () => {
mockRemoteConfig.value.new_free_tier_subscriptions = false
const { isFreeTierEnabled } = useFreeTierOnboarding()
expect(isFreeTierEnabled.value).toBe(false)
})
it('defaults to false when not set in remote config', () => {
mockRemoteConfig.value = { free_tier_credits: 50 }
const { isFreeTierEnabled } = useFreeTierOnboarding()
expect(isFreeTierEnabled.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,28 @@
import { computed, ref } from 'vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export function useFreeTierOnboarding() {
const showEmailForm = ref(false)
const freeTierCredits = computed(() => getTierCredits('free'))
const isFreeTierEnabled = computed(
() => remoteConfig.value.new_free_tier_subscriptions ?? false
)
function switchToEmailForm() {
showEmailForm.value = true
}
function switchToSocialLogin() {
showEmailForm.value = false
}
return {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
}
}