mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +00:00
feat: add Free subscription tier support (#8864)
## Summary Add frontend support for a Free subscription tier — login/signup page restructuring, telemetry instrumentation, and tier-aware billing gating. ## Changes - **What**: - Restructure login/signup pages: OAuth buttons promoted as primary sign-in method, email login available via progressive disclosure - Add Free tier badge on Google sign-up button with dynamic credit count from remote config - Add `FREE` subscription tier to type system (tier pricing, tier rank, registry types) - Add `isFreeTier` computed to `useSubscription()` - Disable credit top-up for Free tier users (dialogService, purchaseCredits, popover CTA) - Show subscription/upgrade dialog instead of top-up dialog when Free tier user hits out-of-credits - Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened` with `free_tier_badge_shown`, track email toggle clicks ## Review Focus - Tier gating logic: Free tier users should see "Upgrade" instead of "Add Credits" and never reach the top-up flow - Telemetry event design for Mixpanel funnel analysis - Progressive disclosure UX on login/signup pages ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||||
|
|
||||||
|
const mockRemoteConfig = vi.hoisted(() => ({
|
||||||
|
value: { free_tier_credits: 50 } as Record<string, unknown>
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||||
|
remoteConfig: mockRemoteConfig
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useFreeTierOnboarding', () => {
|
||||||
|
describe('showEmailForm', () => {
|
||||||
|
it('starts as false', () => {
|
||||||
|
const { showEmailForm } = useFreeTierOnboarding()
|
||||||
|
expect(showEmailForm.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switchToEmailForm sets it to true', () => {
|
||||||
|
const { showEmailForm, switchToEmailForm } = useFreeTierOnboarding()
|
||||||
|
switchToEmailForm()
|
||||||
|
expect(showEmailForm.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switchToSocialLogin sets it back to false', () => {
|
||||||
|
const { showEmailForm, switchToEmailForm, switchToSocialLogin } =
|
||||||
|
useFreeTierOnboarding()
|
||||||
|
switchToEmailForm()
|
||||||
|
switchToSocialLogin()
|
||||||
|
expect(showEmailForm.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('freeTierCredits', () => {
|
||||||
|
it('returns value from remote config', () => {
|
||||||
|
const { freeTierCredits } = useFreeTierOnboarding()
|
||||||
|
expect(freeTierCredits.value).toBe(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isFreeTierEnabled', () => {
|
||||||
|
it('returns true when remote config says enabled', () => {
|
||||||
|
mockRemoteConfig.value.new_free_tier_subscriptions = true
|
||||||
|
const { isFreeTierEnabled } = useFreeTierOnboarding()
|
||||||
|
expect(isFreeTierEnabled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when remote config says disabled', () => {
|
||||||
|
mockRemoteConfig.value.new_free_tier_subscriptions = false
|
||||||
|
const { isFreeTierEnabled } = useFreeTierOnboarding()
|
||||||
|
expect(isFreeTierEnabled.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to false when not set in remote config', () => {
|
||||||
|
mockRemoteConfig.value = { free_tier_credits: 50 }
|
||||||
|
const { isFreeTierEnabled } = useFreeTierOnboarding()
|
||||||
|
expect(isFreeTierEnabled.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
|
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
|
|
||||||
|
export function useFreeTierOnboarding() {
|
||||||
|
const showEmailForm = ref(false)
|
||||||
|
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||||
|
const isFreeTierEnabled = computed(
|
||||||
|
() => remoteConfig.value.new_free_tier_subscriptions ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
function switchToEmailForm() {
|
||||||
|
showEmailForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToSocialLogin() {
|
||||||
|
showEmailForm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showEmailForm,
|
||||||
|
freeTierCredits,
|
||||||
|
isFreeTierEnabled,
|
||||||
|
switchToEmailForm,
|
||||||
|
switchToSocialLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
src/platform/cloud/subscription/utils/tierBenefits.ts
Normal file
67
src/platform/cloud/subscription/utils/tierBenefits.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ interface ListWorkspacesResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SubscriptionTier =
|
export type SubscriptionTier =
|
||||||
|
| 'FREE'
|
||||||
| 'STANDARD'
|
| 'STANDARD'
|
||||||
| 'CREATOR'
|
| 'CREATOR'
|
||||||
| 'PRO'
|
| 'PRO'
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ const handleOpenWorkspaceSettings = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenPlansAndPricing = () => {
|
const handleOpenPlansAndPricing = () => {
|
||||||
subscriptionDialog.show()
|
subscriptionDialog.showPricingTable()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`))
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user