mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 09:27:41 +00:00
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:
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user