mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 02:04:09 +00:00
## 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)
262 lines
6.9 KiB
TypeScript
262 lines
6.9 KiB
TypeScript
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
|
import { createSharedComposable } from '@vueuse/core'
|
|
|
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
|
import {
|
|
KEY_TO_TIER,
|
|
getTierFeatures
|
|
} from '@/platform/cloud/subscription/constants/tierPricing'
|
|
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
|
|
|
import type {
|
|
BalanceInfo,
|
|
BillingActions,
|
|
BillingContext,
|
|
BillingType,
|
|
BillingState,
|
|
SubscriptionInfo
|
|
} from './types'
|
|
import { useLegacyBilling } from './useLegacyBilling'
|
|
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
|
|
|
/**
|
|
* Unified billing context that automatically switches between legacy (user-scoped)
|
|
* and workspace billing based on the active workspace type.
|
|
*
|
|
* - Personal workspaces use legacy billing via /customers/* endpoints
|
|
* - Team workspaces use workspace billing via /billing/* endpoints
|
|
*
|
|
* The context automatically initializes when the workspace changes and provides
|
|
* a unified interface for subscription status, balance, and billing actions.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const {
|
|
* type,
|
|
* subscription,
|
|
* balance,
|
|
* isInitialized,
|
|
* initialize,
|
|
* subscribe
|
|
* } = useBillingContext()
|
|
*
|
|
* // Wait for initialization
|
|
* await initialize()
|
|
*
|
|
* // Check subscription status
|
|
* if (subscription.value?.isActive) {
|
|
* console.log(`Tier: ${subscription.value.tier}`)
|
|
* }
|
|
*
|
|
* // Check balance
|
|
* if (balance.value) {
|
|
* const dollars = balance.value.amountMicros / 1_000_000
|
|
* console.log(`Balance: $${dollars.toFixed(2)}`)
|
|
* }
|
|
* ```
|
|
*/
|
|
function useBillingContextInternal(): BillingContext {
|
|
const store = useTeamWorkspaceStore()
|
|
const { flags } = useFeatureFlags()
|
|
|
|
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
|
null
|
|
)
|
|
const workspaceBillingRef = shallowRef<
|
|
(BillingState & BillingActions) | null
|
|
>(null)
|
|
|
|
const getLegacyBilling = () => {
|
|
if (!legacyBillingRef.value) {
|
|
legacyBillingRef.value = useLegacyBilling()
|
|
}
|
|
return legacyBillingRef.value
|
|
}
|
|
|
|
const getWorkspaceBilling = () => {
|
|
if (!workspaceBillingRef.value) {
|
|
workspaceBillingRef.value = useWorkspaceBilling()
|
|
}
|
|
return workspaceBillingRef.value
|
|
}
|
|
|
|
const isInitialized = ref(false)
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
/**
|
|
* Determines which billing type to use:
|
|
* - If team workspaces feature is disabled: always use legacy (/customers)
|
|
* - If team workspaces feature is enabled:
|
|
* - Personal workspace: use legacy (/customers)
|
|
* - Team workspace: use workspace (/billing)
|
|
*/
|
|
const type = computed<BillingType>(() => {
|
|
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
|
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
|
})
|
|
|
|
const activeContext = computed(() =>
|
|
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
|
)
|
|
|
|
// Proxy state from active context
|
|
const subscription = computed<SubscriptionInfo | null>(() =>
|
|
toValue(activeContext.value.subscription)
|
|
)
|
|
|
|
const balance = computed<BalanceInfo | null>(() =>
|
|
toValue(activeContext.value.balance)
|
|
)
|
|
|
|
const plans = computed(() => toValue(activeContext.value.plans))
|
|
|
|
const currentPlanSlug = computed(() =>
|
|
toValue(activeContext.value.currentPlanSlug)
|
|
)
|
|
|
|
const isActiveSubscription = computed(() =>
|
|
toValue(activeContext.value.isActiveSubscription)
|
|
)
|
|
|
|
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
|
|
|
function getMaxSeats(tierKey: TierKey): number {
|
|
if (type.value === 'legacy') return 1
|
|
|
|
const apiTier = KEY_TO_TIER[tierKey]
|
|
const plan = plans.value.find(
|
|
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
|
|
)
|
|
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
|
}
|
|
|
|
// Sync subscription info to workspace store for display in workspace switcher
|
|
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
|
// This ensures the delete button is enabled after cancellation, even before the period ends
|
|
watch(
|
|
subscription,
|
|
(sub) => {
|
|
if (!sub || store.isInPersonalWorkspace) return
|
|
|
|
store.updateActiveWorkspace({
|
|
isSubscribed: sub.isActive && !sub.isCancelled,
|
|
subscriptionPlan: sub.planSlug
|
|
})
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
// Initialize billing when workspace changes
|
|
watch(
|
|
() => store.activeWorkspace?.id,
|
|
async (newWorkspaceId, oldWorkspaceId) => {
|
|
if (!newWorkspaceId) {
|
|
// No workspace selected - reset state
|
|
isInitialized.value = false
|
|
error.value = null
|
|
return
|
|
}
|
|
|
|
if (newWorkspaceId !== oldWorkspaceId) {
|
|
// Workspace changed - reinitialize
|
|
isInitialized.value = false
|
|
try {
|
|
await initialize()
|
|
} catch (err) {
|
|
// Error is already captured in error ref
|
|
console.error('Failed to initialize billing context:', err)
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
async function initialize(): Promise<void> {
|
|
if (isInitialized.value) return
|
|
|
|
isLoading.value = true
|
|
error.value = null
|
|
try {
|
|
await activeContext.value.initialize()
|
|
isInitialized.value = true
|
|
} catch (err) {
|
|
error.value =
|
|
err instanceof Error ? err.message : 'Failed to initialize billing'
|
|
throw err
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchStatus(): Promise<void> {
|
|
return activeContext.value.fetchStatus()
|
|
}
|
|
|
|
async function fetchBalance(): Promise<void> {
|
|
return activeContext.value.fetchBalance()
|
|
}
|
|
|
|
async function subscribe(
|
|
planSlug: string,
|
|
returnUrl?: string,
|
|
cancelUrl?: string
|
|
) {
|
|
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
|
}
|
|
|
|
async function previewSubscribe(planSlug: string) {
|
|
return activeContext.value.previewSubscribe(planSlug)
|
|
}
|
|
|
|
async function manageSubscription() {
|
|
return activeContext.value.manageSubscription()
|
|
}
|
|
|
|
async function cancelSubscription() {
|
|
return activeContext.value.cancelSubscription()
|
|
}
|
|
|
|
async function fetchPlans() {
|
|
return activeContext.value.fetchPlans()
|
|
}
|
|
|
|
async function requireActiveSubscription() {
|
|
return activeContext.value.requireActiveSubscription()
|
|
}
|
|
|
|
function showSubscriptionDialog() {
|
|
return activeContext.value.showSubscriptionDialog()
|
|
}
|
|
|
|
return {
|
|
type,
|
|
isInitialized,
|
|
subscription,
|
|
balance,
|
|
plans,
|
|
currentPlanSlug,
|
|
isLoading,
|
|
error,
|
|
isActiveSubscription,
|
|
isFreeTier,
|
|
getMaxSeats,
|
|
|
|
initialize,
|
|
fetchStatus,
|
|
fetchBalance,
|
|
subscribe,
|
|
previewSubscribe,
|
|
manageSubscription,
|
|
cancelSubscription,
|
|
fetchPlans,
|
|
requireActiveSubscription,
|
|
showSubscriptionDialog
|
|
}
|
|
}
|
|
|
|
export const useBillingContext = createSharedComposable(
|
|
useBillingContextInternal
|
|
)
|