Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env_example
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging

# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123
2 changes: 2 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface Window {
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
stripe_publishable_key?: string
stripe_pricing_table_id?: string
firebase_config?: {
apiKey: string
authDomain: string
Expand Down
34 changes: 34 additions & 0 deletions src/config/stripePricingTableConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'

export const STRIPE_PRICING_TABLE_SCRIPT_SRC =
'https://js.stripe.com/v3/pricing-table.js'

function getEnvValue(
key: 'VITE_STRIPE_PUBLISHABLE_KEY' | 'VITE_STRIPE_PRICING_TABLE_ID'
) {
return import.meta.env?.[key]
}

export function getStripePricingTableConfig() {
const publishableKey =
remoteConfig.value.stripe_publishable_key ||
window.__CONFIG__?.stripe_publishable_key ||
getEnvValue('VITE_STRIPE_PUBLISHABLE_KEY') ||
''

const pricingTableId =
remoteConfig.value.stripe_pricing_table_id ||
window.__CONFIG__?.stripe_pricing_table_id ||
getEnvValue('VITE_STRIPE_PRICING_TABLE_ID') ||
''

return {
publishableKey,
pricingTableId
}
}

export function hasStripePricingTableConfig() {
const { publishableKey, pricingTableId } = getStripePricingTableConfig()
return Boolean(publishableKey && pricingTableId)
}
11 changes: 11 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
Expand Down Expand Up @@ -1894,10 +1895,20 @@
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"pricingTable": {
"description": "Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.",
"loading": "Loading pricing options...",
"loadError": "We couldn't load the pricing table. Please refresh and try again.",
"missingConfig": "Stripe pricing table configuration missing. Provide the publishable key and pricing table ID via remote config or .env."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner Nodes pricing table"
},
"userSettings": {
Expand Down
120 changes: 120 additions & 0 deletions src/platform/cloud/subscription/components/StripePricingTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<template>
<div
ref="tableContainer"
class="relative w-full rounded-[20px] border border-interface-stroke bg-interface-panel-background"
>
<div
v-if="!hasValidConfig"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-missing-config"
>
{{ $t('subscription.pricingTable.missingConfig') }}
</div>
<div
v-else-if="loadError"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-error"
>
{{ $t('subscription.pricingTable.loadError') }}
</div>
<div
v-else-if="!isReady"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-loading"
>
{{ $t('subscription.pricingTable.loading') }}
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import {
getStripePricingTableConfig,
hasStripePricingTableConfig
} from '@/config/stripePricingTableConfig'
import { useStripePricingTableLoader } from '@/platform/cloud/subscription/composables/useStripePricingTableLoader'
const props = defineProps<{
pricingTableId?: string
publishableKey?: string
}>()
const tableContainer = ref<HTMLDivElement | null>(null)
const isReady = ref(false)
const loadError = ref<string | null>(null)
const lastRenderedKey = ref('')
const stripeElement = ref<HTMLElement | null>(null)
const resolvedConfig = computed(() => {
const fallback = getStripePricingTableConfig()
return {
publishableKey: props.publishableKey || fallback.publishableKey,
pricingTableId: props.pricingTableId || fallback.pricingTableId
}
})
const hasValidConfig = computed(() => {
if (props.publishableKey && props.pricingTableId) return true
return hasStripePricingTableConfig()
})
const { loadScript } = useStripePricingTableLoader()
const renderPricingTable = async () => {
if (!tableContainer.value) return
const { publishableKey, pricingTableId } = resolvedConfig.value
if (!publishableKey || !pricingTableId) {
return
}
const renderKey = `${publishableKey}:${pricingTableId}`
if (renderKey === lastRenderedKey.value && isReady.value) {
return
}
try {
await loadScript()
loadError.value = null
if (!tableContainer.value) {
return
}
if (stripeElement.value) {
stripeElement.value.remove()
stripeElement.value = null
Comment on lines +82 to +84

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM - Missing null check after async operation

Category: bug

Description:
tableContainer.value checked after await but element could be unmounted during loadScript()

Suggestion:
Add early return with null check: if (!tableContainer.value) return after each async operation

Confidence: 70%
Rule: bug_missing_null_check

}
const stripeTable = document.createElement('stripe-pricing-table')
stripeTable.setAttribute('publishable-key', publishableKey)
stripeTable.setAttribute('pricing-table-id', pricingTableId)
stripeTable.style.display = 'block'
stripeTable.style.width = '100%'
stripeTable.style.minHeight = '420px'
tableContainer.value.appendChild(stripeTable)
stripeElement.value = stripeTable
lastRenderedKey.value = renderKey
isReady.value = true
} catch (error) {
console.error('[StripePricingTable] Failed to load pricing table', error)
loadError.value = (error as Error).message
isReady.value = false
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH - Unsafe type assertion without validation

Category: bug

Description:
Error cast to Error type without validating it's actually an Error instance

Suggestion:
Add type guard: loadError.value = error instanceof Error ? error.message : String(error)

Confidence: 85%
Rule: ts_avoid_unsafe_type_assertions

watch(
[resolvedConfig, () => tableContainer.value],
Comment on lines +66 to +104

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW - Long method - renderPricingTable

Category: quality

Description:
The renderPricingTable function is 39 lines long with multiple responsibilities (validation, script loading, DOM manipulation)

Suggestion:
Consider extracting createStripeTableElement() and appendStripeTable() into separate functions

Confidence: 60%
Rule: quality_long_method

() => {
if (!hasValidConfig.value) return
if (!tableContainer.value) return
void renderPricingTable()
},
{ immediate: true }
)
onBeforeUnmount(() => {
stripeElement.value?.remove()
stripeElement.value = null
})
</script>
35 changes: 33 additions & 2 deletions src/platform/cloud/subscription/components/SubscribeButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@

<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'

import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
Expand All @@ -51,12 +52,22 @@ const emit = defineEmits<{
subscribed: []
}>()

const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const { featureFlag } = useFeatureFlags()
const subscriptionTiersEnabled = featureFlag(
'subscription_tiers_enabled',
false
)
const shouldUseStripePricing = computed(
() => isCloud && subscriptionTiersEnabled.value
)
const telemetry = useTelemetry()

const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const isAwaitingStripeSubscription = ref(false)

const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
Expand Down Expand Up @@ -102,11 +113,30 @@ const stopPolling = () => {
isLoading.value = false
}

watch(
() => ({
awaiting: isAwaitingStripeSubscription.value,
isActive: isActiveSubscription.value
}),
({ awaiting, isActive }) => {
if (shouldUseStripePricing.value && awaiting && isActive) {
emit('subscribed')
isAwaitingStripeSubscription.value = false
}
}
)

const handleSubscribe = async () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
}

if (shouldUseStripePricing.value) {
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}

isLoading.value = true
try {
await subscribe()
Expand All @@ -120,5 +150,6 @@ const handleSubscribe = async () => {

onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>
Loading
Loading