From 8c3738fb77728070be047cdbc42bdadebbe58f31 Mon Sep 17 00:00:00 2001 From: Hunter Date: Tue, 24 Feb 2026 23:28:51 -0500 Subject: [PATCH] feat: add Free subscription tier support (#8864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../registry-types/src/comfyRegistryTypes.ts | 2 +- .../topbar/CurrentUserPopoverLegacy.test.ts | 9 +- .../topbar/CurrentUserPopoverLegacy.vue | 7 +- src/composables/billing/types.ts | 5 + src/composables/billing/useBillingContext.ts | 3 + src/composables/billing/useLegacyBilling.ts | 6 + src/locales/en/main.json | 34 ++++- .../cloud/onboarding/CloudLoginView.vue | 103 ++++++++----- .../cloud/onboarding/CloudSignupView.vue | 135 ++++++++++++------ .../CloudSubscriptionRedirectView.vue | 2 + .../composables/useFreeTierOnboarding.test.ts | 61 ++++++++ .../composables/useFreeTierOnboarding.ts | 28 ++++ .../components/FreeTierDialogContent.vue | 122 ++++++++++++++++ .../components/PricingTable.test.ts | 1 + .../subscription/components/PricingTable.vue | 18 ++- .../components/SubscribeButton.vue | 6 +- .../components/SubscriptionBenefits.vue | 19 ++- .../SubscriptionPanelContentLegacy.vue | 52 ++----- .../SubscriptionRequiredDialogContent.vue | 19 ++- .../composables/useSubscription.ts | 15 +- .../composables/useSubscriptionDialog.ts | 52 ++++++- .../subscription/constants/tierPricing.ts | 15 +- .../utils/subscriptionTierRank.ts | 4 +- .../cloud/subscription/utils/tierBenefits.ts | 67 +++++++++ src/platform/remoteConfig/types.ts | 2 + src/platform/telemetry/TelemetryRegistry.ts | 8 +- .../cloud/MixpanelTelemetryProvider.ts | 8 +- src/platform/telemetry/types.ts | 12 +- src/platform/workspace/api/workspaceApi.ts | 1 + .../CurrentUserPopoverWorkspace.vue | 2 +- .../components/PricingTableWorkspace.vue | 2 +- ...SubscriptionAddPaymentPreviewWorkspace.vue | 4 +- .../SubscriptionPanelContentWorkspace.vue | 59 +++----- ...criptionRequiredDialogContentWorkspace.vue | 17 ++- ...SubscriptionTransitionPreviewWorkspace.vue | 4 +- .../components/WorkspaceSwitcherPopover.vue | 1 + .../composables/useWorkspaceBilling.ts | 8 ++ src/scripts/app.ts | 10 +- src/services/dialogService.ts | 18 ++- 39 files changed, 720 insertions(+), 221 deletions(-) create mode 100644 src/platform/cloud/onboarding/composables/useFreeTierOnboarding.test.ts create mode 100644 src/platform/cloud/onboarding/composables/useFreeTierOnboarding.ts create mode 100644 src/platform/cloud/subscription/components/FreeTierDialogContent.vue create mode 100644 src/platform/cloud/subscription/utils/tierBenefits.ts diff --git a/packages/registry-types/src/comfyRegistryTypes.ts b/packages/registry-types/src/comfyRegistryTypes.ts index f2d69fd29d..452f91816f 100644 --- a/packages/registry-types/src/comfyRegistryTypes.ts +++ b/packages/registry-types/src/comfyRegistryTypes.ts @@ -3952,7 +3952,7 @@ export interface components { * @description The subscription tier level * @enum {string} */ - SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION"; + SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION"; /** * @description The subscription billing duration * @enum {string} diff --git a/src/components/topbar/CurrentUserPopoverLegacy.test.ts b/src/components/topbar/CurrentUserPopoverLegacy.test.ts index b95101848a..f83a3257ff 100644 --- a/src/components/topbar/CurrentUserPopoverLegacy.test.ts +++ b/src/components/topbar/CurrentUserPopoverLegacy.test.ts @@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ })) // Mock the useSubscriptionDialog composable -const mockSubscriptionDialogShow = vi.fn() +const mockShowPricingTable = vi.fn() vi.mock( '@/platform/cloud/subscription/composables/useSubscriptionDialog', () => ({ useSubscriptionDialog: vi.fn(() => ({ - show: mockSubscriptionDialogShow, + show: vi.fn(), + showPricingTable: mockShowPricingTable, hide: vi.fn() })) }) @@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => { await plansPricingItem.trigger('click') - // Verify subscription dialog show was called - expect(mockSubscriptionDialogShow).toHaveBeenCalled() + // Verify showPricingTable was called + expect(mockShowPricingTable).toHaveBeenCalled() // Verify close event was emitted expect(wrapper.emitted('close')).toBeTruthy() diff --git a/src/components/topbar/CurrentUserPopoverLegacy.vue b/src/components/topbar/CurrentUserPopoverLegacy.vue index e6e0bdd871..ff40f1c600 100644 --- a/src/components/topbar/CurrentUserPopoverLegacy.vue +++ b/src/components/topbar/CurrentUserPopoverLegacy.vue @@ -195,7 +195,10 @@ const formattedBalance = computed(() => { const canUpgrade = computed(() => { const tier = subscriptionTier.value 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 = () => { - subscriptionDialog.show() + subscriptionDialog.showPricingTable() emit('close') } diff --git a/src/composables/billing/types.ts b/src/composables/billing/types.ts index 852cfffdb9..1c2f9477b6 100644 --- a/src/composables/billing/types.ts +++ b/src/composables/billing/types.ts @@ -70,6 +70,11 @@ export interface BillingState { * Equivalent to `subscription.value?.isActive ?? false` */ isActiveSubscription: ComputedRef + /** + * 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 } export interface BillingContext extends BillingState, BillingActions { diff --git a/src/composables/billing/useBillingContext.ts b/src/composables/billing/useBillingContext.ts index 05d2e93658..ce1a36cfae 100644 --- a/src/composables/billing/useBillingContext.ts +++ b/src/composables/billing/useBillingContext.ts @@ -120,6 +120,8 @@ function useBillingContextInternal(): BillingContext { toValue(activeContext.value.isActiveSubscription) ) + const isFreeTier = computed(() => subscription.value?.tier === 'FREE') + function getMaxSeats(tierKey: TierKey): number { if (type.value === 'legacy') return 1 @@ -238,6 +240,7 @@ function useBillingContextInternal(): BillingContext { isLoading, error, isActiveSubscription, + isFreeTier, getMaxSeats, initialize, diff --git a/src/composables/billing/useLegacyBilling.ts b/src/composables/billing/useLegacyBilling.ts index 1bb6a5731f..0e94a798bb 100644 --- a/src/composables/billing/useLegacyBilling.ts +++ b/src/composables/billing/useLegacyBilling.ts @@ -40,6 +40,7 @@ export function useLegacyBilling(): BillingState & BillingActions { const error = ref(null) const isActiveSubscription = computed(() => legacyIsActiveSubscription.value) + const isFreeTier = computed(() => subscriptionTier.value === 'FREE') const subscription = computed(() => { if (!legacyIsActiveSubscription.value && !subscriptionTier.value) { @@ -85,6 +86,10 @@ export function useLegacyBilling(): BillingState & BillingActions { error.value = null try { 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 } catch (err) { error.value = @@ -173,6 +178,7 @@ export function useLegacyBilling(): BillingState & BillingActions { isLoading, error, isActiveSubscription, + isFreeTier, // Actions initialize, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index d914c4165f..5c3f2412af 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1987,6 +1987,7 @@ "newUser": "New here?", "userAvatar": "User Avatar", "signUp": "Sign up", + "signUpFreeTierPromo": "New here? {signUp} with Google to get {credits} free credits every month.", "emailLabel": "Email", "emailPlaceholder": "Enter your email", "passwordLabel": "Password", @@ -2011,7 +2012,12 @@ "failed": "Login failed", "insecureContextWarning": "This connection is insecure (HTTP) - your credentials may be intercepted by attackers if you proceed to login.", "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": { "title": "Create an account", @@ -2025,7 +2031,8 @@ "signUpWithGoogle": "Sign up with Google", "signUpWithGithub": "Sign up with Github", "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": "Log Out", @@ -2216,10 +2223,15 @@ "invoiceHistory": "Invoice history", "benefits": { "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", "tiers": { + "free": { + "name": "Free" + }, "founder": { "name": "Founder's Edition" }, @@ -2253,6 +2265,21 @@ "haveQuestions": "Have questions or wondering about enterprise?", "contactUs": "Contact us", "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", "plansAndPricing": "Plans & pricing", "managePlan": "Manage plan", @@ -2280,6 +2307,7 @@ "upgradeTo": "Upgrade to {plan}", "changeTo": "Change to {plan}", "maxDuration": { + "free": "30 min", "standard": "30 min", "creator": "30 min", "pro": "1 hr", diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue index a4fea37a44..be2c8f1766 100644 --- a/src/platform/cloud/onboarding/CloudLoginView.vue +++ b/src/platform/cloud/onboarding/CloudLoginView.vue @@ -2,14 +2,30 @@
-
+

{{ t('auth.login.title') }}

-

- {{ t('auth.login.newUser') }} + + + + +

+ {{ t('auth.login.newUser') }} {{ t('auth.login.signUp') }} @@ -20,36 +36,49 @@ {{ t('auth.login.insecureContextWarning') }} - - + - -

+

@@ -75,7 +104,6 @@ diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index cb6e590602..e8097d146c 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -24,6 +24,7 @@ const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({}))) vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ useSubscription: () => ({ isActiveSubscription: computed(() => mockIsActiveSubscription.value), + isFreeTier: computed(() => false), subscriptionTier: computed(() => mockSubscriptionTier.value), isYearlySubscription: computed(() => mockIsYearlySubscription.value), subscriptionStatus: ref(null) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 9035bb2852..a14a22f3c8 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -272,7 +272,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { components } from '@/types/comfyRegistryTypes' type SubscriptionTier = components['schemas']['SubscriptionTier'] -type CheckoutTierKey = Exclude +type CheckoutTierKey = Exclude type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly` const getCheckoutTier = ( @@ -344,8 +344,12 @@ const tiers: PricingTierConfig[] = [ isPopular: false } ] -const { isActiveSubscription, subscriptionTier, isYearlySubscription } = - useSubscription() +const { + isActiveSubscription, + isFreeTier, + subscriptionTier, + isYearlySubscription +} = useSubscription() const telemetry = useTelemetry() const { userId } = storeToRefs(useFirebaseAuthStore()) const { accessBillingPortal, reportError } = useFirebaseAuthActions() @@ -356,6 +360,10 @@ const loadingTier = ref(null) const popover = ref() const currentBillingCycle = ref('yearly') +const hasPaidSubscription = computed( + () => isActiveSubscription.value && !isFreeTier.value +) + const currentTierKey = computed(() => subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null ) @@ -392,7 +400,7 @@ const getButtonLabel = (tier: PricingTierConfig): string => { ? t('subscription.tierNameYearly', { name: tier.name }) : tier.name - return isActiveSubscription.value + return hasPaidSubscription.value ? t('subscription.changeTo', { plan: planName }) : t('subscription.subscribeTo', { plan: planName }) } @@ -427,7 +435,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync( loadingTier.value = tierKey try { - if (isActiveSubscription.value) { + if (hasPaidSubscription.value) { const checkoutAttribution = await getCheckoutAttributionForCloud() if (userId.value) { telemetry?.trackBeginCheckout({ diff --git a/src/platform/cloud/subscription/components/SubscribeButton.vue b/src/platform/cloud/subscription/components/SubscribeButton.vue index fd8ac0c9b4..0accadfb0b 100644 --- a/src/platform/cloud/subscription/components/SubscribeButton.vue +++ b/src/platform/cloud/subscription/components/SubscribeButton.vue @@ -24,6 +24,7 @@ import { onBeforeUnmount, ref, watch } from 'vue' import Button from '@/components/ui/button/Button.vue' import { useBillingContext } from '@/composables/billing/useBillingContext' import { isCloud } from '@/platform/distribution/types' +import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' import { useTelemetry } from '@/platform/telemetry' import { cn } from '@/utils/tailwindUtil' @@ -46,6 +47,7 @@ const emit = defineEmits<{ }>() const { isActiveSubscription, showSubscriptionDialog } = useBillingContext() +const { subscriptionTier } = useSubscription() const isAwaitingStripeSubscription = ref(false) watch( @@ -60,7 +62,9 @@ watch( const handleSubscribe = () => { if (isCloud) { - useTelemetry()?.trackSubscription('subscribe_clicked') + useTelemetry()?.trackSubscription('subscribe_clicked', { + current_tier: subscriptionTier.value?.toLowerCase() + }) } isAwaitingStripeSubscription.value = true showSubscriptionDialog() diff --git a/src/platform/cloud/subscription/components/SubscriptionBenefits.vue b/src/platform/cloud/subscription/components/SubscriptionBenefits.vue index a3c18ef1a6..5ea7edf968 100644 --- a/src/platform/cloud/subscription/components/SubscriptionBenefits.vue +++ b/src/platform/cloud/subscription/components/SubscriptionBenefits.vue @@ -3,7 +3,11 @@

- {{ $t('subscription.benefits.benefit1') }} + {{ + isFreeTier + ? $t('subscription.benefits.benefit1FreeTier') + : $t('subscription.benefits.benefit1') + }}
@@ -13,7 +17,18 @@ {{ $t('subscription.benefits.benefit2') }}
+ +
+ + + {{ $t('subscription.benefits.benefit3') }} + +
- + diff --git a/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue b/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue index a6d5f063b6..e7098e5336 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue @@ -33,7 +33,7 @@