mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 07:30:11 +00:00
feat: handling subscription tier button link parameter (#7553)
## Summary Discussion here: https://comfy-organization.slack.com/archives/C0A0XANFJRE/p1764899027465379 Implement: Subscription tier query parameter for direct checkout flow Example button link: `/cloud/subscribe?tier=standard` `tier` could be `standard`, `creator` or `pro` `cycle` could be `monthly` or `yearly`. it is optional, and `monthly` by default. <!-- One sentence describing what changed and why. --> ## Changes - **What**: <!-- Core functionality added/modified --> - Add a landing page called `CloudSubscriptionRedirectView.vue` to handling the subscription tier button link parameter - Extract subscription handling logic from `PriceTable.vue` - **Breaking**: <!-- Any breaking changes (if none, remove this line) --> - Code change touched `PriceTable.vue` - **Dependencies**: <!-- New dependencies (if none, remove this line) --> ## Review Focus - link will redirect to login url, when cloud app not login - after login, the cloud app will redirect to CloudSubscriptionRedirect page - wait for several seconds, the cloud app will be redirected to checkout page <!-- Critical design decisions or edge cases that need attention --> <!-- If this PR fixes an issue, uncomment and update the line below --> <!-- Fixes #ISSUE_NUMBER --> ## Screenshots (if applicable)  <!-- Add screenshots or video recording to help explain your changes --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7553-feat-handling-subscription-tier-button-link-parameter-2cb6d73d365081ee9580e89090248300) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
23
lint-staged.config.mjs
Normal file
23
lint-staged.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ export const useFirebaseAuthActions = () => {
|
||||
}, reportError)
|
||||
|
||||
const accessBillingPortal = wrapWithErrorHandlingAsync<
|
||||
[targetTier?: BillingPortalTargetTier],
|
||||
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
|
||||
void
|
||||
>(async (targetTier) => {
|
||||
>(async (targetTier, openInNewTab = true) => {
|
||||
const response = await authStore.accessBillingPortal(targetTier)
|
||||
if (!response.billing_portal_url) {
|
||||
throw new Error(
|
||||
@@ -115,7 +115,11 @@ export const useFirebaseAuthActions = () => {
|
||||
})
|
||||
)
|
||||
}
|
||||
window.open(response.billing_portal_url, '_blank')
|
||||
if (openInNewTab) {
|
||||
window.open(response.billing_portal_url, '_blank')
|
||||
} else {
|
||||
globalThis.location.href = response.billing_portal_url
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
@@ -2166,6 +2166,7 @@
|
||||
"renderErrorState": "Render Error State"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"skipToCloudApp": "Skip to the cloud app",
|
||||
"survey": {
|
||||
"title": "Cloud Survey",
|
||||
"placeholder": "Survey questions placeholder",
|
||||
|
||||
@@ -84,6 +84,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
|
||||
@@ -91,12 +92,12 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const navigateToSignup = () => {
|
||||
void router.push({ name: 'cloud-signup', query: route.query })
|
||||
const navigateToSignup = async () => {
|
||||
await router.push({ name: 'cloud-signup', query: route.query })
|
||||
}
|
||||
|
||||
const onSuccess = async () => {
|
||||
@@ -105,6 +106,13 @@ const onSuccess = async () => {
|
||||
summary: 'Login Completed',
|
||||
life: 2000
|
||||
})
|
||||
|
||||
const previousFullPath = getSafePreviousFullPath(route.query)
|
||||
if (previousFullPath) {
|
||||
await router.replace(previousFullPath)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push({ name: 'cloud-user-check' })
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -110,13 +111,13 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const userIsInChina = ref(false)
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const navigateToLogin = () => {
|
||||
void router.push({ name: 'cloud-login', query: route.query })
|
||||
const navigateToLogin = async () => {
|
||||
await router.push({ name: 'cloud-login', query: route.query })
|
||||
}
|
||||
|
||||
const onSuccess = async () => {
|
||||
@@ -125,7 +126,14 @@ const onSuccess = async () => {
|
||||
summary: 'Sign up Completed',
|
||||
life: 2000
|
||||
})
|
||||
// Direct redirect to main app - email verification removed
|
||||
|
||||
const previousFullPath = getSafePreviousFullPath(route.query)
|
||||
if (previousFullPath) {
|
||||
await router.replace(previousFullPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Default redirect to the normal onboarding flow
|
||||
await router.push({ path: '/', query: route.query })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CloudSubscriptionRedirectView from './CloudSubscriptionRedirectView.vue'
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// Router mocks
|
||||
let mockQuery: Record<string, unknown> = {}
|
||||
const mockRouterPush = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: mockQuery
|
||||
}),
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush
|
||||
})
|
||||
}))
|
||||
|
||||
// Firebase / subscription mocks
|
||||
const authActionMocks = vi.hoisted(() => ({
|
||||
reportError: vi.fn(),
|
||||
accessBillingPortal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: () => authActionMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
<T extends (...args: never[]) => unknown>(fn: T) =>
|
||||
(...args: Parameters<T>) =>
|
||||
fn(...args)
|
||||
})
|
||||
}))
|
||||
|
||||
const subscriptionMocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isInitialized: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => subscriptionMocks
|
||||
}))
|
||||
|
||||
// Avoid real network / isCloud behavior
|
||||
const mockPerformSubscriptionCheckout = vi.fn()
|
||||
vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
|
||||
performSubscriptionCheckout: (...args: unknown[]) =>
|
||||
mockPerformSubscriptionCheckout(...args)
|
||||
}))
|
||||
|
||||
const createI18nInstance = () =>
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
cloudOnboarding: {
|
||||
skipToCloudApp: 'Skip to the cloud app'
|
||||
},
|
||||
g: {
|
||||
comfyOrgLogoAlt: 'Comfy org logo'
|
||||
},
|
||||
subscription: {
|
||||
subscribeTo: 'Subscribe to {plan}',
|
||||
tiers: {
|
||||
standard: { name: 'Standard' },
|
||||
creator: { name: 'Creator' },
|
||||
pro: { name: 'Pro' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountView = async (query: Record<string, unknown>) => {
|
||||
mockQuery = query
|
||||
|
||||
const wrapper = mount(CloudSubscriptionRedirectView, {
|
||||
global: {
|
||||
plugins: [createI18nInstance()]
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
return { wrapper }
|
||||
}
|
||||
|
||||
describe('CloudSubscriptionRedirectView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQuery = {}
|
||||
subscriptionMocks.isActiveSubscription.value = false
|
||||
subscriptionMocks.isInitialized.value = true
|
||||
})
|
||||
|
||||
test('redirects to home when subscriptionType is missing', async () => {
|
||||
await mountView({})
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
test('redirects to home when subscriptionType is invalid', async () => {
|
||||
await mountView({ tier: 'invalid' })
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
test('shows subscription copy when subscriptionType is valid', async () => {
|
||||
const { wrapper } = await mountView({ tier: 'creator' })
|
||||
|
||||
// Should not redirect to home
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
|
||||
|
||||
// Shows copy under logo
|
||||
expect(wrapper.text()).toContain('Subscribe to Creator')
|
||||
|
||||
// Triggers checkout flow
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
false
|
||||
)
|
||||
|
||||
// Shows loading affordances
|
||||
expect(wrapper.findComponent({ name: 'ProgressSpinner' }).exists()).toBe(
|
||||
true
|
||||
)
|
||||
const skipLink = wrapper.get('a[href="/"]')
|
||||
expect(skipLink.text()).toContain('Skip to the cloud app')
|
||||
})
|
||||
|
||||
test('opens billing portal when subscription is already active', async () => {
|
||||
subscriptionMocks.isActiveSubscription.value = true
|
||||
|
||||
await mountView({ tier: 'creator' })
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
|
||||
expect(authActionMocks.accessBillingPortal).toHaveBeenCalledTimes(1)
|
||||
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('uses first value when subscriptionType is an array', async () => {
|
||||
const { wrapper } = await mountView({
|
||||
tier: ['creator', 'pro']
|
||||
})
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
|
||||
expect(wrapper.text()).toContain('Subscribe to Creator')
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
130
src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue
Normal file
130
src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { until } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
|
||||
import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isActiveSubscription, isInitialized } = useSubscription()
|
||||
|
||||
const selectedTierKey = ref<TierKey | null>(null)
|
||||
|
||||
const tierDisplayName = computed(() => {
|
||||
if (!selectedTierKey.value) return ''
|
||||
const names: Record<TierKey, string> = {
|
||||
standard: t('subscription.tiers.standard.name'),
|
||||
creator: t('subscription.tiers.creator.name'),
|
||||
pro: t('subscription.tiers.pro.name'),
|
||||
founder: t('subscription.tiers.founder.name')
|
||||
}
|
||||
return names[selectedTierKey.value]
|
||||
})
|
||||
|
||||
const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
const rawType = route.query.tier
|
||||
const rawCycle = route.query.cycle
|
||||
let tierKeyParam: string | null = null
|
||||
let cycleParam = 'monthly'
|
||||
|
||||
if (typeof rawType === 'string') {
|
||||
tierKeyParam = rawType
|
||||
} else if (Array.isArray(rawType) && rawType[0]) {
|
||||
tierKeyParam = rawType[0]
|
||||
}
|
||||
|
||||
if (typeof rawCycle === 'string') {
|
||||
cycleParam = rawCycle
|
||||
} else if (Array.isArray(rawCycle) && rawCycle[0]) {
|
||||
cycleParam = rawCycle[0]
|
||||
}
|
||||
|
||||
if (!tierKeyParam) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
|
||||
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
const tierKey = tierKeyParam as TierKey
|
||||
|
||||
selectedTierKey.value = tierKey
|
||||
|
||||
const validCycles: BillingCycle[] = ['monthly', 'yearly']
|
||||
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
|
||||
cycleParam = 'monthly'
|
||||
}
|
||||
|
||||
if (!isInitialized.value) {
|
||||
await until(isInitialized).toBe(true)
|
||||
}
|
||||
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
cycleParam as BillingCycle,
|
||||
false
|
||||
)
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
onMounted(() => {
|
||||
void runRedirect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-comfy-menu-secondary-bg"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<img
|
||||
src="/assets/images/comfy-logo-single.svg"
|
||||
:alt="t('g.comfyOrgLogoAlt')"
|
||||
class="h-16 w-16"
|
||||
/>
|
||||
<p
|
||||
v-if="selectedTierKey"
|
||||
class="font-inter text-base font-normal leading-normal text-base-foreground"
|
||||
>
|
||||
{{
|
||||
t('subscription.subscribeTo', {
|
||||
plan: tierDisplayName
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ProgressSpinner
|
||||
v-if="selectedTierKey"
|
||||
class="h-8 w-8"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<Button
|
||||
v-if="selectedTierKey"
|
||||
as="a"
|
||||
href="/"
|
||||
link
|
||||
:label="t('cloudOnboarding.skipToCloudApp')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,7 +78,7 @@ const {
|
||||
}
|
||||
|
||||
// User is fully onboarded (active or whitelist check disabled)
|
||||
window.location.href = '/'
|
||||
globalThis.location.href = '/'
|
||||
}),
|
||||
null,
|
||||
{ resetOnExecute: false }
|
||||
|
||||
@@ -65,6 +65,13 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudAuthTimeoutView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'subscribe',
|
||||
name: 'cloud-subscribe',
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
38
src/platform/cloud/onboarding/utils/previousFullPath.test.ts
Normal file
38
src/platform/cloud/onboarding/utils/previousFullPath.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import { getSafePreviousFullPath } from './previousFullPath'
|
||||
|
||||
describe('getSafePreviousFullPath', () => {
|
||||
test('returns null when missing', () => {
|
||||
expect(getSafePreviousFullPath({})).toBeNull()
|
||||
})
|
||||
|
||||
test('decodes and returns internal relative paths', () => {
|
||||
const query: LocationQuery = {
|
||||
previousFullPath: encodeURIComponent('/some/path?x=1')
|
||||
}
|
||||
expect(getSafePreviousFullPath(query)).toBe('/some/path?x=1')
|
||||
})
|
||||
|
||||
test('rejects protocol-relative urls', () => {
|
||||
const query: LocationQuery = {
|
||||
previousFullPath: encodeURIComponent('//evil.com')
|
||||
}
|
||||
expect(getSafePreviousFullPath(query)).toBeNull()
|
||||
})
|
||||
|
||||
test('rejects absolute external urls', () => {
|
||||
const query: LocationQuery = {
|
||||
previousFullPath: encodeURIComponent('https://evil.com/path')
|
||||
}
|
||||
expect(getSafePreviousFullPath(query)).toBeNull()
|
||||
})
|
||||
|
||||
test('rejects malformed encodings', () => {
|
||||
const query: LocationQuery = {
|
||||
previousFullPath: '%E0%A4%A'
|
||||
}
|
||||
expect(getSafePreviousFullPath(query)).toBeNull()
|
||||
})
|
||||
})
|
||||
27
src/platform/cloud/onboarding/utils/previousFullPath.ts
Normal file
27
src/platform/cloud/onboarding/utils/previousFullPath.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
const decodeQueryParam = (value: string): string | null => {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isSafeInternalRedirectPath = (path: string): boolean => {
|
||||
// Must be a relative in-app path. Disallow protocol-relative URLs ("//evil.com").
|
||||
return path.startsWith('/') && !path.startsWith('//')
|
||||
}
|
||||
|
||||
export const getSafePreviousFullPath = (
|
||||
query: LocationQuery
|
||||
): string | null => {
|
||||
const raw = query.previousFullPath
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (!value) return null
|
||||
|
||||
const decoded = decodeQueryParam(value)
|
||||
if (!decoded) return null
|
||||
|
||||
return isSafeInternalRedirectPath(decoded) ? decoded : null
|
||||
}
|
||||
@@ -252,7 +252,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import {
|
||||
@@ -263,13 +262,10 @@ import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
@@ -332,7 +328,6 @@ const tiers: PricingTierConfig[] = [
|
||||
]
|
||||
|
||||
const { n } = useI18n()
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
|
||||
useSubscription()
|
||||
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
|
||||
@@ -384,12 +379,13 @@ const getButtonLabel = (tier: PricingTierConfig): string => {
|
||||
: t('subscription.subscribeTo', { plan: planName })
|
||||
}
|
||||
|
||||
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
|
||||
isCurrentPlan(tier.key)
|
||||
? 'secondary'
|
||||
: tier.key === 'creator'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
const getButtonSeverity = (
|
||||
tier: PricingTierConfig
|
||||
): 'primary' | 'secondary' => {
|
||||
if (isCurrentPlan(tier.key)) return 'secondary'
|
||||
if (tier.key === 'creator') return 'primary'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const getButtonTextClass = (tier: PricingTierConfig): string =>
|
||||
tier.key === 'creator'
|
||||
@@ -405,47 +401,6 @@ const getAnnualTotal = (tier: PricingTierConfig): number =>
|
||||
const getCreditsDisplay = (tier: PricingTierConfig): number =>
|
||||
tier.pricing.credits * (currentBillingCycle.value === 'yearly' ? 12 : 1)
|
||||
|
||||
const initiateCheckout = async (tierKey: CheckoutTierKey) => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
|
||||
const response = await fetch(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to initiate checkout'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// If JSON parsing fails, try to get text response or use HTTP status
|
||||
try {
|
||||
const errorText = await response.text()
|
||||
errorMessage =
|
||||
errorText || `HTTP ${response.status} ${response.statusText}`
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: errorMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
async (tierKey: CheckoutTierKey) => {
|
||||
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
|
||||
@@ -475,10 +430,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
await accessBillingPortal(checkoutTier)
|
||||
}
|
||||
} else {
|
||||
const response = await initiateCheckout(tierKey)
|
||||
if (response.checkout_url) {
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}
|
||||
await performSubscriptionCheckout(tierKey, currentBillingCycle.value)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -28,6 +28,7 @@ export type CloudSubscriptionStatusResponse = NonNullable<
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
const isInitialized = ref(false)
|
||||
|
||||
const isSubscribedOrIsNotCloud = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
@@ -200,10 +201,15 @@ function useSubscriptionInternal() {
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn) {
|
||||
await fetchSubscriptionStatus()
|
||||
try {
|
||||
await fetchSubscriptionStatus()
|
||||
} finally {
|
||||
isInitialized.value = true
|
||||
}
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
stopCancellationWatcher()
|
||||
isInitialized.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -244,6 +250,7 @@ function useSubscriptionInternal() {
|
||||
return {
|
||||
// State
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
isInitialized,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
|
||||
const getCheckoutTier = (
|
||||
tierKey: TierKey,
|
||||
billingCycle: BillingCycle
|
||||
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
|
||||
|
||||
/**
|
||||
* Core subscription checkout logic shared between PricingTable and
|
||||
* SubscriptionRedirectView. Handles:
|
||||
* - Ensuring the user is authenticated
|
||||
* - Calling the backend checkout endpoint
|
||||
* - Normalizing error responses
|
||||
* - Opening the checkout URL in a new tab when available
|
||||
*
|
||||
* Callers are responsible for:
|
||||
* - Guarding on cloud-only behavior (isCloud)
|
||||
* - Managing loading state
|
||||
* - Wrapping with error handling (e.g. useErrorHandling)
|
||||
*/
|
||||
export async function performSubscriptionCheckout(
|
||||
tierKey: TierKey,
|
||||
currentBillingCycle: BillingCycle,
|
||||
openInNewTab: boolean = true
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const authHeader = await getAuthHeader()
|
||||
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
|
||||
|
||||
const response = await fetch(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to initiate checkout'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// If JSON parsing fails, try to get text response or use HTTP status
|
||||
try {
|
||||
const errorText = await response.text()
|
||||
errorMessage =
|
||||
errorText || `HTTP ${response.status} ${response.statusText}`
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: errorMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
if (openInNewTab) {
|
||||
window.open(data.checkout_url, '_blank')
|
||||
} else {
|
||||
globalThis.location.href = data.checkout_url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,17 @@ if (isCloud) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const query =
|
||||
to.fullPath === '/'
|
||||
? undefined
|
||||
: { previousFullPath: encodeURIComponent(to.fullPath) }
|
||||
|
||||
// Check if route requires authentication
|
||||
if (to.meta.requiresAuth && !isLoggedIn) {
|
||||
return next({ name: 'cloud-login' })
|
||||
return next({
|
||||
name: 'cloud-login',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
// Handle other protected routes
|
||||
@@ -164,7 +172,10 @@ if (isCloud) {
|
||||
}
|
||||
|
||||
// For web, redirect to login
|
||||
return next({ name: 'cloud-login' })
|
||||
return next({
|
||||
name: 'cloud-login',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
// User is logged in - check if they need onboarding (when enabled)
|
||||
|
||||
Reference in New Issue
Block a user