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

@@ -3952,7 +3952,7 @@ export interface components {
* @description The subscription tier level * @description The subscription tier level
* @enum {string} * @enum {string}
*/ */
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION"; SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/** /**
* @description The subscription billing duration * @description The subscription billing duration
* @enum {string} * @enum {string}

View File

@@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
})) }))
// Mock the useSubscriptionDialog composable // Mock the useSubscriptionDialog composable
const mockSubscriptionDialogShow = vi.fn() const mockShowPricingTable = vi.fn()
vi.mock( vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog', '@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({ () => ({
useSubscriptionDialog: vi.fn(() => ({ useSubscriptionDialog: vi.fn(() => ({
show: mockSubscriptionDialogShow, show: vi.fn(),
showPricingTable: mockShowPricingTable,
hide: vi.fn() hide: vi.fn()
})) }))
}) })
@@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => {
await plansPricingItem.trigger('click') await plansPricingItem.trigger('click')
// Verify subscription dialog show was called // Verify showPricingTable was called
expect(mockSubscriptionDialogShow).toHaveBeenCalled() expect(mockShowPricingTable).toHaveBeenCalled()
// Verify close event was emitted // Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy() expect(wrapper.emitted('close')).toBeTruthy()

View File

@@ -195,7 +195,10 @@ const formattedBalance = computed(() => {
const canUpgrade = computed(() => { const canUpgrade = computed(() => {
const tier = subscriptionTier.value const tier = subscriptionTier.value
return ( return (
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR' tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
) )
}) })
@@ -205,7 +208,7 @@ const handleOpenUserSettings = () => {
} }
const handleOpenPlansAndPricing = () => { const handleOpenPlansAndPricing = () => {
subscriptionDialog.show() subscriptionDialog.showPricingTable()
emit('close') emit('close')
} }

View File

@@ -70,6 +70,11 @@ export interface BillingState {
* Equivalent to `subscription.value?.isActive ?? false` * Equivalent to `subscription.value?.isActive ?? false`
*/ */
isActiveSubscription: ComputedRef<boolean> isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
} }
export interface BillingContext extends BillingState, BillingActions { export interface BillingContext extends BillingState, BillingActions {

View File

@@ -120,6 +120,8 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription) toValue(activeContext.value.isActiveSubscription)
) )
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
function getMaxSeats(tierKey: TierKey): number { function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1 if (type.value === 'legacy') return 1
@@ -238,6 +240,7 @@ function useBillingContextInternal(): BillingContext {
isLoading, isLoading,
error, error,
isActiveSubscription, isActiveSubscription,
isFreeTier,
getMaxSeats, getMaxSeats,
initialize, initialize,

View File

@@ -40,6 +40,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value) const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
const subscription = computed<SubscriptionInfo | null>(() => { const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) { if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
@@ -85,6 +86,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error.value = null error.value = null
try { try {
await Promise.all([fetchStatus(), fetchBalance()]) await Promise.all([fetchStatus(), fetchBalance()])
// Re-fetch balance if free tier credits were just lazily granted
if (isFreeTier.value && balance.value?.amountMicros === 0) {
await fetchBalance()
}
isInitialized.value = true isInitialized.value = true
} catch (err) { } catch (err) {
error.value = error.value =
@@ -173,6 +178,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading, isLoading,
error, error,
isActiveSubscription, isActiveSubscription,
isFreeTier,
// Actions // Actions
initialize, initialize,

View File

@@ -1987,6 +1987,7 @@
"newUser": "New here?", "newUser": "New here?",
"userAvatar": "User Avatar", "userAvatar": "User Avatar",
"signUp": "Sign up", "signUp": "Sign up",
"signUpFreeTierPromo": "New here? {signUp} with Google to get {credits} free credits every month.",
"emailLabel": "Email", "emailLabel": "Email",
"emailPlaceholder": "Enter your email", "emailPlaceholder": "Enter your email",
"passwordLabel": "Password", "passwordLabel": "Password",
@@ -2011,7 +2012,12 @@
"failed": "Login failed", "failed": "Login failed",
"insecureContextWarning": "This connection is insecure (HTTP) - your credentials may be intercepted by attackers if you proceed to login.", "insecureContextWarning": "This connection is insecure (HTTP) - your credentials may be intercepted by attackers if you proceed to login.",
"questionsContactPrefix": "Questions? Contact us at", "questionsContactPrefix": "Questions? Contact us at",
"noAssociatedUser": "There is no Comfy user associated with the provided API key" "noAssociatedUser": "There is no Comfy user associated with the provided API key",
"useEmailInstead": "Use email instead",
"freeTierBadge": "Eligible for Free Tier",
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
"backToSocialLogin": "Sign up with Google or Github instead"
}, },
"signup": { "signup": {
"title": "Create an account", "title": "Create an account",
@@ -2025,7 +2031,8 @@
"signUpWithGoogle": "Sign up with Google", "signUpWithGoogle": "Sign up with Google",
"signUpWithGithub": "Sign up with Github", "signUpWithGithub": "Sign up with Github",
"regionRestrictionChina": "In accordance with local regulatory requirements, our services are temporarily unavailable to users located in China.", "regionRestrictionChina": "In accordance with local regulatory requirements, our services are temporarily unavailable to users located in China.",
"personalDataConsentLabel": "I agree to the processing of my personal data." "personalDataConsentLabel": "I agree to the processing of my personal data.",
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
}, },
"signOut": { "signOut": {
"signOut": "Log Out", "signOut": "Log Out",
@@ -2216,10 +2223,15 @@
"invoiceHistory": "Invoice history", "invoiceHistory": "Invoice history",
"benefits": { "benefits": {
"benefit1": "Monthly credits for Partner Nodes — top up when needed", "benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job" "benefit1FreeTier": "More monthly credits, top up anytime",
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
}, },
"yearlyDiscount": "20% DISCOUNT", "yearlyDiscount": "20% DISCOUNT",
"tiers": { "tiers": {
"free": {
"name": "Free"
},
"founder": { "founder": {
"name": "Founder's Edition" "name": "Founder's Edition"
}, },
@@ -2253,6 +2265,21 @@
"haveQuestions": "Have questions or wondering about enterprise?", "haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us", "contactUs": "Contact us",
"viewEnterprise": "View enterprise", "viewEnterprise": "View enterprise",
"freeTier": {
"title": "You're on the Free plan",
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
"descriptionGeneric": "Your free plan includes a monthly credit allowance to try Comfy Cloud.",
"nextRefresh": "Your credits refresh on {date}.",
"subscribeCta": "Subscribe for more",
"outOfCredits": {
"title": "You're out of free credits",
"subtitle": "Subscribe to unlock top-ups and more"
},
"topUpBlocked": {
"title": "Unlock top-ups and more"
},
"upgradeCta": "View plans"
},
"partnerNodesCredits": "Partner nodes pricing", "partnerNodesCredits": "Partner nodes pricing",
"plansAndPricing": "Plans & pricing", "plansAndPricing": "Plans & pricing",
"managePlan": "Manage plan", "managePlan": "Manage plan",
@@ -2280,6 +2307,7 @@
"upgradeTo": "Upgrade to {plan}", "upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}", "changeTo": "Change to {plan}",
"maxDuration": { "maxDuration": {
"free": "30 min",
"standard": "30 min", "standard": "30 min",
"creator": "30 min", "creator": "30 min",
"pro": "1 hr", "pro": "1 hr",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
<template>
<div class="relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
size="icon"
variant="muted-textonly"
class="rounded-full absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
:aria-label="$t('g.close')"
@click="$emit('close', false)"
>
<i class="pi pi-times" />
</Button>
<div
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
>
<video
autoplay
loop
muted
playsinline
class="h-full min-w-[125%] object-cover p-0"
style="margin-left: -20%"
>
<source
src="/assets/images/cloud-subscription.webm"
type="video/webm"
/>
</video>
</div>
<div class="col-span-3 flex flex-col justify-between p-8">
<div>
<div class="flex flex-col gap-6">
<div class="text-sm text-text-primary">
<template v-if="reason === 'out_of_credits'">
{{ $t('subscription.freeTier.outOfCredits.title') }}
</template>
<template v-else-if="reason === 'top_up_blocked'">
{{ $t('subscription.freeTier.topUpBlocked.title') }}
</template>
<template v-else>
{{ $t('subscription.freeTier.title') }}
</template>
</div>
<p
v-if="reason === 'out_of_credits'"
class="m-0 text-sm text-text-secondary"
>
{{ $t('subscription.freeTier.outOfCredits.subtitle') }}
</p>
<p
v-if="!reason || reason === 'subscription_required'"
class="m-0 text-sm text-text-secondary"
>
{{
freeTierCredits
? $t('subscription.freeTier.description', {
credits: freeTierCredits.toLocaleString()
})
: $t('subscription.freeTier.descriptionGeneric')
}}
</p>
<p
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
class="m-0 text-sm text-text-secondary"
>
{{
$t('subscription.freeTier.nextRefresh', {
date: formattedRenewalDate
})
}}
</p>
</div>
<SubscriptionBenefits is-free-tier class="mt-6 text-muted" />
</div>
<div class="flex flex-col pt-8">
<Button
class="w-full rounded-lg bg-[var(--color-accent-blue,#0B8CE9)] px-4 py-2 font-inter text-sm font-bold text-white hover:bg-[var(--color-accent-blue,#0B8CE9)]/90"
@click="$emit('upgrade')"
>
{{
reason === 'out_of_credits' || reason === 'top_up_blocked'
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
reason?: SubscriptionDialogReason
}>()
defineEmits<{
close: [subscribed: boolean]
upgrade: []
}>()
const { formattedRenewalDate } = useSubscription()
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -24,6 +24,7 @@ const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({ useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value), isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value), subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value), isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null) subscriptionStatus: ref(null)

View File

@@ -272,7 +272,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes' import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier'] type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'> type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly` type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
const getCheckoutTier = ( const getCheckoutTier = (
@@ -344,8 +344,12 @@ const tiers: PricingTierConfig[] = [
isPopular: false isPopular: false
} }
] ]
const { isActiveSubscription, subscriptionTier, isYearlySubscription } = const {
useSubscription() isActiveSubscription,
isFreeTier,
subscriptionTier,
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry() const telemetry = useTelemetry()
const { userId } = storeToRefs(useFirebaseAuthStore()) const { userId } = storeToRefs(useFirebaseAuthStore())
const { accessBillingPortal, reportError } = useFirebaseAuthActions() const { accessBillingPortal, reportError } = useFirebaseAuthActions()
@@ -356,6 +360,10 @@ const loadingTier = ref<CheckoutTierKey | null>(null)
const popover = ref() const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly') const currentBillingCycle = ref<BillingCycle>('yearly')
const hasPaidSubscription = computed(
() => isActiveSubscription.value && !isFreeTier.value
)
const currentTierKey = computed<TierKey | null>(() => const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
) )
@@ -392,7 +400,7 @@ const getButtonLabel = (tier: PricingTierConfig): string => {
? t('subscription.tierNameYearly', { name: tier.name }) ? t('subscription.tierNameYearly', { name: tier.name })
: tier.name : tier.name
return isActiveSubscription.value return hasPaidSubscription.value
? t('subscription.changeTo', { plan: planName }) ? t('subscription.changeTo', { plan: planName })
: t('subscription.subscribeTo', { plan: planName }) : t('subscription.subscribeTo', { plan: planName })
} }
@@ -427,7 +435,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
loadingTier.value = tierKey loadingTier.value = tierKey
try { try {
if (isActiveSubscription.value) { if (hasPaidSubscription.value) {
const checkoutAttribution = await getCheckoutAttributionForCloud() const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) { if (userId.value) {
telemetry?.trackBeginCheckout({ telemetry?.trackBeginCheckout({

View File

@@ -24,6 +24,7 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext' import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
@@ -46,6 +47,7 @@ const emit = defineEmits<{
}>() }>()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext() const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const isAwaitingStripeSubscription = ref(false) const isAwaitingStripeSubscription = ref(false)
watch( watch(
@@ -60,7 +62,9 @@ watch(
const handleSubscribe = () => { const handleSubscribe = () => {
if (isCloud) { if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked') useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
} }
isAwaitingStripeSubscription.value = true isAwaitingStripeSubscription.value = true
showSubscriptionDialog() showSubscriptionDialog()

View File

@@ -3,7 +3,11 @@
<div class="flex items-center gap-2 py-2"> <div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" /> <i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary"> <span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }} {{
isFreeTier
? $t('subscription.benefits.benefit1FreeTier')
: $t('subscription.benefits.benefit1')
}}
</span> </span>
</div> </div>
@@ -13,7 +17,18 @@
{{ $t('subscription.benefits.benefit2') }} {{ $t('subscription.benefits.benefit2') }}
</span> </span>
</div> </div>
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit3') }}
</span>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
const { isFreeTier = false } = defineProps<{
isFreeTier?: boolean
}>()
</script>

View File

@@ -33,7 +33,7 @@
</div> </div>
<Button <Button
v-if="isActiveSubscription" v-if="isActiveSubscription && !isFreeTier"
variant="secondary" variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected" class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click=" @click="
@@ -213,9 +213,10 @@ import {
DEFAULT_TIER_KEY, DEFAULT_TIER_KEY,
TIER_TO_KEY, TIER_TO_KEY,
getTierCredits, getTierCredits,
getTierFeatures,
getTierPrice getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing' } from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions() const authActions = useFirebaseAuthActions()
@@ -224,6 +225,7 @@ const { t, n } = useI18n()
const { const {
isActiveSubscription, isActiveSubscription,
isCancelled, isCancelled,
isFreeTier,
formattedRenewalDate, formattedRenewalDate,
formattedEndDate, formattedEndDate,
subscriptionTier, subscriptionTier,
@@ -264,6 +266,7 @@ const creditsRemainingLabel = computed(() =>
const planTotalCredits = computed(() => { const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value) const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits const total = isYearlySubscription.value ? credits * 12 : credits
return n(total) return n(total)
}) })
@@ -272,48 +275,9 @@ const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}` () => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
) )
// Tier benefits for v-for loop const tierBenefits = computed((): TierBenefit[] =>
type BenefitType = 'metric' | 'feature' getCommonTierBenefits(tierKey.value, t, n)
)
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
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')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } = const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits() useSubscriptionCredits()

View File

@@ -81,7 +81,11 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="inline-flex items-center gap-2"> <div class="inline-flex items-center gap-2">
<div class="text-sm text-text-primary"> <div class="text-sm text-text-primary">
{{ $t('subscription.required.title') }} {{
reason === 'out_of_credits'
? $t('credits.topUp.insufficientTitle')
: $t('subscription.required.title')
}}
</div> </div>
<CloudBadge <CloudBadge
reverse-order reverse-order
@@ -91,6 +95,13 @@
/> />
</div> </div>
<p
v-if="reason === 'out_of_credits'"
class="m-0 text-sm text-text-secondary"
>
{{ $t('credits.topUp.insufficientMessage') }}
</p>
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="text-4xl font-bold">{{ formattedMonthlyPrice }}</span> <span class="text-4xl font-bold">{{ formattedMonthlyPrice }}</span>
<span class="text-xl">{{ $t('subscription.perMonth') }}</span> <span class="text-xl">{{ $t('subscription.perMonth') }}</span>
@@ -131,9 +142,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const props = defineProps<{ const { onClose, reason } = defineProps<{
onClose: () => void onClose: () => void
reason?: SubscriptionDialogReason
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -234,7 +247,7 @@ const handleSubscribed = () => {
const handleClose = () => { const handleClose = () => {
stopPolling() stopPolling()
props.onClose() onClose()
} }
const handleContactUs = async () => { const handleContactUs = async () => {

View File

@@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n' import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { import {
FirebaseAuthStoreError, FirebaseAuthStoreError,
@@ -77,6 +78,8 @@ function useSubscriptionInternal() {
() => subscriptionStatus.value?.subscription_tier ?? null () => subscriptionStatus.value?.subscription_tier ?? null
) )
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
const subscriptionDuration = computed( const subscriptionDuration = computed(
() => subscriptionStatus.value?.subscription_duration ?? null () => subscriptionStatus.value?.subscription_duration ?? null
) )
@@ -130,12 +133,17 @@ function useSubscriptionInternal() {
window.open(response.checkout_url, '_blank') window.open(response.checkout_url, '_blank')
}, reportError) }, reportError)
const showSubscriptionDialog = () => { const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
if (isCloud) { if (isCloud) {
useTelemetry()?.trackSubscription('modal_opened') useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
} }
void showSubscriptionRequiredDialog() void showSubscriptionRequiredDialog(options)
} }
/** /**
@@ -278,6 +286,7 @@ function useSubscriptionInternal() {
formattedRenewalDate, formattedRenewalDate,
formattedEndDate, formattedEndDate,
subscriptionTier, subscriptionTier,
isFreeTier,
subscriptionDuration, subscriptionDuration,
isYearlySubscription, isYearlySubscription,
subscriptionTierName, subscriptionTierName,

View File

@@ -2,21 +2,30 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const DIALOG_KEY = 'subscription-required' const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
export const useSubscriptionDialog = () => { export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags() const { flags } = useFeatureFlags()
const dialogService = useDialogService() const dialogService = useDialogService()
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore() const workspaceStore = useTeamWorkspaceStore()
const { isFreeTier } = useSubscription()
function hide() { function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY }) dialogStore.closeDialog({ key: DIALOG_KEY })
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
} }
function show() { function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
const useWorkspaceVariant = const useWorkspaceVariant =
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
@@ -34,7 +43,8 @@ export const useSubscriptionDialog = () => {
key: DIALOG_KEY, key: DIALOG_KEY,
component, component,
props: { props: {
onClose: hide onClose: hide,
reason: options?.reason
}, },
dialogComponentProps: { dialogComponentProps: {
style: 'width: min(1328px, 95vw); max-height: 958px;', style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -51,8 +61,46 @@ export const useSubscriptionDialog = () => {
}) })
} }
function show(options?: { reason?: SubscriptionDialogReason }) {
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
)
dialogService.showLayoutDialog({
key: FREE_TIER_DIALOG_KEY,
component,
props: {
reason: options?.reason,
onClose: hide,
onUpgrade: () => {
hide()
showPricingTable(options)
}
},
dialogComponentProps: {
style: 'width: min(640px, 95vw);',
pt: {
root: {
class: 'rounded-2xl bg-transparent'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return
}
showPricingTable(options)
}
return { return {
show, show,
showPricingTable,
hide hide
} }
} }

View File

@@ -1,10 +1,12 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { components } from '@/types/comfyRegistryTypes' import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier'] type SubscriptionTier = components['schemas']['SubscriptionTier']
export type TierKey = 'standard' | 'creator' | 'pro' | 'founder' export type TierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder'
export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = { export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
FREE: 'free',
STANDARD: 'standard', STANDARD: 'standard',
CREATOR: 'creator', CREATOR: 'creator',
PRO: 'pro', PRO: 'pro',
@@ -12,6 +14,7 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
} }
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = { export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
free: 'FREE',
standard: 'STANDARD', standard: 'STANDARD',
creator: 'CREATOR', creator: 'CREATOR',
pro: 'PRO', pro: 'PRO',
@@ -25,7 +28,10 @@ export interface TierPricing {
videoEstimate: number videoEstimate: number
} }
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = { export const TIER_PRICING: Record<
Exclude<TierKey, 'free' | 'founder'>,
TierPricing
> = {
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 380 }, standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 380 },
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 670 }, creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 670 },
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 1915 } pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 1915 }
@@ -37,6 +43,7 @@ interface TierFeatures {
} }
const TIER_FEATURES: Record<TierKey, TierFeatures> = { const TIER_FEATURES: Record<TierKey, TierFeatures> = {
free: { customLoRAs: false, maxMembers: 1 },
standard: { customLoRAs: false, maxMembers: 1 }, standard: { customLoRAs: false, maxMembers: 1 },
creator: { customLoRAs: true, maxMembers: 5 }, creator: { customLoRAs: true, maxMembers: 5 },
pro: { customLoRAs: true, maxMembers: 20 }, pro: { customLoRAs: true, maxMembers: 20 },
@@ -49,12 +56,14 @@ const FOUNDER_MONTHLY_PRICE = 20
const FOUNDER_MONTHLY_CREDITS = 5460 const FOUNDER_MONTHLY_CREDITS = 5460
export function getTierPrice(tierKey: TierKey, isYearly = false): number { export function getTierPrice(tierKey: TierKey, isYearly = false): number {
if (tierKey === 'free') return 0
if (tierKey === 'founder') return FOUNDER_MONTHLY_PRICE if (tierKey === 'founder') return FOUNDER_MONTHLY_PRICE
const pricing = TIER_PRICING[tierKey] const pricing = TIER_PRICING[tierKey]
return isYearly ? pricing.yearly : pricing.monthly return isYearly ? pricing.yearly : pricing.monthly
} }
export function getTierCredits(tierKey: TierKey): number { export function getTierCredits(tierKey: TierKey): number | null {
if (tierKey === 'free') return remoteConfig.value.free_tier_credits ?? null
if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS
return TIER_PRICING[tierKey].credits return TIER_PRICING[tierKey].credits
} }

View File

@@ -2,7 +2,7 @@ import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricin
export type BillingCycle = 'monthly' | 'yearly' export type BillingCycle = 'monthly' | 'yearly'
type RankedTierKey = Exclude<TierKey, 'founder'> type RankedTierKey = Exclude<TierKey, 'founder' | 'free'>
type RankedPlanKey = `${BillingCycle}-${RankedTierKey}` type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
interface PlanDescriptor { interface PlanDescriptor {
@@ -28,7 +28,7 @@ const toRankedPlanKey = (
tierKey: TierKey, tierKey: TierKey,
billingCycle: BillingCycle billingCycle: BillingCycle
): RankedPlanKey | null => { ): RankedPlanKey | null => {
if (tierKey === 'founder') return null if (tierKey === 'founder' || tierKey === 'free') return null
return `${billingCycle}-${tierKey}` as RankedPlanKey return `${billingCycle}-${tierKey}` as RankedPlanKey
} }

View File

@@ -0,0 +1,67 @@
import {
getTierCredits,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
type BenefitType = 'metric' | 'feature' | 'icon'
export interface TierBenefit {
key: string
type: BenefitType
label: string
value?: string
icon?: string
}
export function getCommonTierBenefits(
key: TierKey,
t: (key: string, params?: Record<string, unknown>) => string,
n: (value: number) => string
): TierBenefit[] {
const benefits: TierBenefit[] = []
const isFree = key === 'free'
if (isFree) {
const credits = getTierCredits(key)
if (credits !== null) {
benefits.push({
key: 'monthlyCredits',
type: 'metric',
value: n(credits),
label: t('subscription.monthlyCreditsLabel')
})
}
}
benefits.push({
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
})
benefits.push({
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
})
if (!isFree) {
benefits.push({
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
})
}
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
}

View File

@@ -44,4 +44,6 @@ export type RemoteConfig = {
team_workspaces_enabled?: boolean team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean node_library_essentials_enabled?: boolean
free_tier_credits?: number
new_free_tier_subscriptions?: boolean
} }

View File

@@ -15,6 +15,7 @@ import type {
PageViewMetadata, PageViewMetadata,
PageVisibilityMetadata, PageVisibilityMetadata,
SettingChangedMetadata, SettingChangedMetadata,
SubscriptionMetadata,
SurveyResponses, SurveyResponses,
TabCountMetadata, TabCountMetadata,
TelemetryDispatcher, TelemetryDispatcher,
@@ -65,8 +66,11 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUserLoggedIn?.()) this.dispatch((provider) => provider.trackUserLoggedIn?.())
} }
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { trackSubscription(
this.dispatch((provider) => provider.trackSubscription?.(event)) event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
this.dispatch((provider) => provider.trackSubscription?.(event, metadata))
} }
trackBeginCheckout(metadata: BeginCheckoutMetadata): void { trackBeginCheckout(metadata: BeginCheckoutMetadata): void {

View File

@@ -35,6 +35,7 @@ import type {
PageVisibilityMetadata, PageVisibilityMetadata,
RunButtonProperties, RunButtonProperties,
SettingChangedMetadata, SettingChangedMetadata,
SubscriptionMetadata,
SurveyResponses, SurveyResponses,
TabCountMetadata, TabCountMetadata,
TelemetryEventName, TelemetryEventName,
@@ -222,13 +223,16 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN) this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
} }
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
const eventName = const eventName =
event === 'modal_opened' event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED ? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED : TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
this.trackEvent(eventName) this.trackEvent(eventName, metadata)
} }
trackAddApiCreditButtonClicked(): void { trackAddApiCreditButtonClicked(): void {

View File

@@ -12,6 +12,7 @@
* 3. Check dist/assets/*.js files contain no tracking code * 3. Check dist/assets/*.js files contain no tracking code
*/ */
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService' import type { AuditLog } from '@/services/customerEventsService'
@@ -301,6 +302,11 @@ export interface CheckoutAttributionMetadata {
wbraid?: string wbraid?: string
} }
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
}
export interface BeginCheckoutMetadata export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata { extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string user_id: string
@@ -321,7 +327,10 @@ export interface TelemetryProvider {
trackUserLoggedIn?(): void trackUserLoggedIn?(): void
// Subscription flow events // Subscription flow events
trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void trackSubscription?(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackMonthlySubscriptionSucceeded?(): void trackMonthlySubscriptionSucceeded?(): void
trackMonthlySubscriptionCancelled?(): void trackMonthlySubscriptionCancelled?(): void
@@ -512,3 +521,4 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata | HelpCenterClosedMetadata
| WorkflowCreatedMetadata | WorkflowCreatedMetadata
| EnterLinearMetadata | EnterLinearMetadata
| SubscriptionMetadata

View File

@@ -78,6 +78,7 @@ interface ListWorkspacesResponse {
} }
export type SubscriptionTier = export type SubscriptionTier =
| 'FREE'
| 'STANDARD' | 'STANDARD'
| 'CREATOR' | 'CREATOR'
| 'PRO' | 'PRO'

View File

@@ -296,7 +296,7 @@ const handleOpenWorkspaceSettings = () => {
} }
const handleOpenPlansAndPricing = () => { const handleOpenPlansAndPricing = () => {
subscriptionDialog.show() subscriptionDialog.showPricingTable()
emit('close') emit('close')
} }

View File

@@ -298,7 +298,7 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type { components } from '@/types/comfyRegistryTypes' import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier'] type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'> type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
interface Props { interface Props {
isLoading?: boolean isLoading?: boolean

View File

@@ -182,7 +182,7 @@ import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspac
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
interface Props { interface Props {
tierKey: Exclude<TierKey, 'founder'> tierKey: Exclude<TierKey, 'free' | 'founder'>
billingCycle?: BillingCycle billingCycle?: BillingCycle
isLoading?: boolean isLoading?: boolean
previewData?: PreviewSubscribeResponse | null previewData?: PreviewSubscribeResponse | null
@@ -213,7 +213,7 @@ const displayPrice = computed(() => {
return getTierPrice(tierKey, billingCycle === 'yearly') return getTierPrice(tierKey, billingCycle === 'yearly')
}) })
const displayCredits = computed(() => n(getTierCredits(tierKey))) const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs) const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs)
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`)) const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))

View File

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

View File

@@ -23,6 +23,15 @@
<i class="pi pi-times text-xl" /> <i class="pi pi-times text-xl" />
</Button> </Button>
<div v-if="reason === 'out_of_credits'" class="text-center">
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0">
{{ $t('credits.topUp.insufficientTitle') }}
</h2>
<p class="m-0 mt-2 text-sm text-text-secondary">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
</div>
<!-- Pricing Table Step --> <!-- Pricing Table Step -->
<PricingTableWorkspace <PricingTableWorkspace
v-if="checkoutStep === 'pricing'" v-if="checkoutStep === 'pricing'"
@@ -75,16 +84,18 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi' import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi' import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore' import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import PricingTableWorkspace from './PricingTableWorkspace.vue' import PricingTableWorkspace from './PricingTableWorkspace.vue'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue' import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue' import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
type CheckoutStep = 'pricing' | 'preview' type CheckoutStep = 'pricing' | 'preview'
type CheckoutTierKey = Exclude<TierKey, 'founder'> type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
const props = defineProps<{ const { onClose, reason } = defineProps<{
onClose: () => void onClose: () => void
reason?: SubscriptionDialogReason
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -314,7 +325,7 @@ async function handleResubscribe() {
} }
function handleClose() { function handleClose() {
props.onClose() onClose()
} }
</script> </script>

View File

@@ -208,7 +208,7 @@ const currentDisplayCredits = computed(() => {
| 'standard' | 'standard'
| 'creator' | 'creator'
| 'pro' | 'pro'
return n(getTierCredits(tierKey)) return n(getTierCredits(tierKey) ?? 0)
}) })
const newDisplayCredits = computed(() => { const newDisplayCredits = computed(() => {
@@ -216,7 +216,7 @@ const newDisplayCredits = computed(() => {
| 'standard' | 'standard'
| 'creator' | 'creator'
| 'pro' | 'pro'
return n(getTierCredits(tierKey)) return n(getTierCredits(tierKey) ?? 0)
}) })
const currentPeriodEndDate = computed(() => const currentPeriodEndDate = computed(() =>

View File

@@ -143,6 +143,7 @@ const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscription } = useBillingContext() const { subscription } = useBillingContext()
const tierKeyMap: Record<string, string> = { const tierKeyMap: Record<string, string> = {
FREE: 'free',
STANDARD: 'standard', STANDARD: 'standard',
CREATOR: 'creator', CREATOR: 'creator',
PRO: 'pro', PRO: 'pro',

View File

@@ -37,6 +37,9 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
const isActiveSubscription = computed( const isActiveSubscription = computed(
() => statusData.value?.is_active ?? false () => statusData.value?.is_active ?? false
) )
const isFreeTier = computed(
() => statusData.value?.subscription_tier === 'FREE'
)
const subscription = computed<SubscriptionInfo | null>(() => { const subscription = computed<SubscriptionInfo | null>(() => {
const status = statusData.value const status = statusData.value
@@ -141,6 +144,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
error.value = null error.value = null
try { try {
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()]) await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
// Re-fetch balance if free tier credits were just lazily granted
if (isFreeTier.value && balance.value?.amountMicros === 0) {
await fetchBalance()
}
isInitialized.value = true isInitialized.value = true
} catch (err) { } catch (err) {
error.value = error.value =
@@ -295,6 +302,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
isLoading, isLoading,
error, error,
isActiveSubscription, isActiveSubscription,
isFreeTier,
// Actions // Actions
initialize, initialize,

View File

@@ -51,7 +51,6 @@ import {
DOMWidgetImpl DOMWidgetImpl
} from '@/scripts/domWidget' } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog' import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useExtensionService } from '@/services/extensionService' import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService' import { useLitegraphService } from '@/services/litegraphService'
@@ -741,12 +740,9 @@ export class ComfyApp {
'Payment Required: Please add credits to your account to use this node.' 'Payment Required: Please add credits to your account to use this node.'
) )
) { ) {
const { isActiveSubscription } = useBillingContext() useDialogService().showTopUpCreditsDialog({
if (isActiveSubscription.value) { isInsufficientCredits: true
useDialogService().showTopUpCreditsDialog({ })
isInsufficientCredits: true
})
}
} else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) { } else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
useExecutionErrorStore().showErrorOverlay() useExecutionErrorStore().showErrorOverlay()
} else { } else {

View File

@@ -17,6 +17,7 @@ import type {
} from '@/stores/dialogStore' } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers' import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
// Lazy loaders for dialogs - components are loaded on first use // Lazy loaders for dialogs - components are loaded on first use
const lazyApiNodesSignInContent = () => const lazyApiNodesSignInContent = () =>
@@ -274,8 +275,15 @@ export const useDialogService = () => {
async function showTopUpCreditsDialog(options?: { async function showTopUpCreditsDialog(options?: {
isInsufficientCredits?: boolean isInsufficientCredits?: boolean
}) { }) {
const { isActiveSubscription, type } = useBillingContext() const { isActiveSubscription, isFreeTier, type } = useBillingContext()
if (!isActiveSubscription.value) return if (!isActiveSubscription.value || isFreeTier.value) {
await showSubscriptionRequiredDialog({
reason: options?.isInsufficientCredits
? 'out_of_credits'
: 'top_up_blocked'
})
return
}
const component = const component =
type.value === 'workspace' type.value === 'workspace'
@@ -392,7 +400,9 @@ export const useDialogService = () => {
}) })
} }
async function showSubscriptionRequiredDialog() { async function showSubscriptionRequiredDialog(options?: {
reason?: SubscriptionDialogReason
}) {
if (!isCloud || !window.__CONFIG__?.subscription_required) { if (!isCloud || !window.__CONFIG__?.subscription_required) {
return return
} }
@@ -400,7 +410,7 @@ export const useDialogService = () => {
const { useSubscriptionDialog } = const { useSubscriptionDialog } =
await import('@/platform/cloud/subscription/composables/useSubscriptionDialog') await import('@/platform/cloud/subscription/composables/useSubscriptionDialog')
const { show } = useSubscriptionDialog() const { show } = useSubscriptionDialog()
show() show(options)
} }
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off // Workspace dialogs - dynamically imported to avoid bundling when feature flag is off