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

@@ -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()