mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 06:19:58 +00:00
[backport cloud/1.34] feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7292)
Backport of #7288 to `cloud/1.34` Automatically created by backport workflow. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7292-backport-cloud-1-34-feat-add-Stripe-pricing-table-integration-for-subscription-dialog--2c46d73d36508111869ddf32de921b29) by [Unito](https://www.unito.io) Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -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
global.d.ts
vendored
2
global.d.ts
vendored
@@ -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
|
||||
|
||||
34
src/config/stripePricingTableConfig.ts
Normal file
34
src/config/stripePricingTableConfig.ts
Normal file
@@ -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'
|
||||
|
||||
interface StripePricingTableConfig {
|
||||
publishableKey: string
|
||||
pricingTableId: string
|
||||
}
|
||||
|
||||
function getEnvValue(
|
||||
key: 'VITE_STRIPE_PUBLISHABLE_KEY' | 'VITE_STRIPE_PRICING_TABLE_ID'
|
||||
) {
|
||||
return import.meta.env[key]
|
||||
}
|
||||
|
||||
export function getStripePricingTableConfig(): StripePricingTableConfig {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -1883,10 +1884,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": {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<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 } 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(() => {
|
||||
const { publishableKey, pricingTableId } = resolvedConfig.value
|
||||
return Boolean(publishableKey && pricingTableId)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[resolvedConfig, () => tableContainer.value],
|
||||
() => {
|
||||
if (!hasValidConfig.value) return
|
||||
if (!tableContainer.value) return
|
||||
void renderPricingTable()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stripeElement.value?.remove()
|
||||
stripeElement.value = null
|
||||
})
|
||||
</script>
|
||||
@@ -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'
|
||||
@@ -51,12 +52,18 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
|
||||
useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
const shouldUseStripePricing = computed(
|
||||
() => isCloud && Boolean(flags.subscriptionTiersEnabled)
|
||||
)
|
||||
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
|
||||
@@ -102,11 +109,27 @@ const stopPolling = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
[isAwaitingStripeSubscription, isActiveSubscription],
|
||||
([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()
|
||||
@@ -120,5 +143,6 @@ const handleSubscribe = async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
isAwaitingStripeSubscription.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
<template>
|
||||
<div class="relative grid h-full grid-cols-5">
|
||||
<div
|
||||
v-if="showStripePricingTable"
|
||||
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
|
||||
>
|
||||
{{ $t('subscription.required.title') }}
|
||||
<CloudBadge
|
||||
reverse-order
|
||||
no-padding
|
||||
background-color="var(--p-dialog-background)"
|
||||
use-subscription
|
||||
/>
|
||||
</div>
|
||||
<div class="text-3xl font-semibold leading-tight md:text-4xl">
|
||||
{{ $t('subscription.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StripePricingTable class="flex-1" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.haveQuestions') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="$t('subscription.contactUs')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-comments"
|
||||
icon-pos="right"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
|
||||
@click="handleContactUs"
|
||||
/>
|
||||
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
|
||||
<Button
|
||||
:label="$t('subscription.viewEnterprise')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="right"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
|
||||
@click="handleViewEnterprise"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="legacy-dialog relative grid h-full grid-cols-5">
|
||||
<!-- Custom close button -->
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
@@ -7,7 +70,7 @@
|
||||
rounded
|
||||
class="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="onClose"
|
||||
@click="handleClose"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -72,13 +135,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
@@ -86,19 +155,119 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { formattedMonthlyPrice } = useSubscription()
|
||||
const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
|
||||
useSubscription()
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const subscriptionTiersEnabled = featureFlag(
|
||||
'subscription_tiers_enabled',
|
||||
false
|
||||
)
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const showStripePricingTable = computed(
|
||||
() =>
|
||||
subscriptionTiersEnabled.value &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required
|
||||
)
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
|
||||
let pollInterval: number | null = null
|
||||
let pollStartTime = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollStartTime = Date.now()
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
watch(
|
||||
showStripePricingTable,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showStripePricingTable.value) {
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubscribed = () => {
|
||||
emit('close', true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const handleContactUs = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleViewEnterprise = () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'docs',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.p-button) {
|
||||
.legacy-dialog :deep(.p-button) {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { STRIPE_PRICING_TABLE_SCRIPT_SRC } from '@/config/stripePricingTableConfig'
|
||||
|
||||
function useStripePricingTableLoaderInternal() {
|
||||
const isLoaded = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
let pendingPromise: Promise<void> | null = null
|
||||
|
||||
const resolveLoaded = () => {
|
||||
isLoaded.value = true
|
||||
isLoading.value = false
|
||||
pendingPromise = null
|
||||
}
|
||||
|
||||
const resolveError = (err: Error) => {
|
||||
error.value = err
|
||||
isLoading.value = false
|
||||
pendingPromise = null
|
||||
}
|
||||
|
||||
const loadScript = (): Promise<void> => {
|
||||
if (isLoaded.value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (pendingPromise) {
|
||||
return pendingPromise
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${STRIPE_PRICING_TABLE_SCRIPT_SRC}"]`
|
||||
)
|
||||
|
||||
if (existingScript) {
|
||||
isLoading.value = true
|
||||
|
||||
pendingPromise = new Promise<void>((resolve, reject) => {
|
||||
existingScript.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
existingScript.dataset.loaded = 'true'
|
||||
resolveLoaded()
|
||||
resolve()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
const err = new Error('Stripe pricing table script failed to load')
|
||||
resolveError(err)
|
||||
reject(err)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
// Check if script already loaded after attaching listeners
|
||||
if (
|
||||
existingScript.dataset.loaded === 'true' ||
|
||||
(existingScript as any).readyState === 'complete' ||
|
||||
(existingScript as any).complete
|
||||
) {
|
||||
existingScript.dataset.loaded = 'true'
|
||||
resolveLoaded()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
return pendingPromise
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
pendingPromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = STRIPE_PRICING_TABLE_SCRIPT_SRC
|
||||
script.async = true
|
||||
script.dataset.loaded = 'false'
|
||||
|
||||
script.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
script.dataset.loaded = 'true'
|
||||
resolveLoaded()
|
||||
resolve()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
const err = new Error('Stripe pricing table script failed to load')
|
||||
resolveError(err)
|
||||
reject(err)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return pendingPromise
|
||||
}
|
||||
|
||||
return {
|
||||
loadScript,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
export const useStripePricingTableLoader = createSharedComposable(
|
||||
useStripePricingTableLoaderInternal
|
||||
)
|
||||
@@ -9,11 +9,11 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
type CloudSubscriptionCheckoutResponse = {
|
||||
@@ -37,7 +37,7 @@ function useSubscriptionInternal() {
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { showSubscriptionRequiredDialog } = useDialogService()
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
@@ -102,7 +102,7 @@ function useSubscriptionInternal() {
|
||||
useTelemetry()?.trackSubscription('modal_opened')
|
||||
}
|
||||
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
void showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
const shouldWatchCancellation = (): boolean =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -7,6 +10,14 @@ const DIALOG_KEY = 'subscription-required'
|
||||
export const useSubscriptionDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const showStripeDialog = computed(
|
||||
() =>
|
||||
flags.subscriptionTiersEnabled &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required
|
||||
)
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
@@ -25,7 +36,19 @@ export const useSubscriptionDialog = () => {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 700px;'
|
||||
style: showStripeDialog.value
|
||||
? 'width: min(1100px, 90vw); max-height: 90vh;'
|
||||
: 'width: 700px;',
|
||||
pt: showStripeDialog.value
|
||||
? {
|
||||
root: {
|
||||
class: '!rounded-[32px] overflow-visible'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,4 +38,6 @@ export type RemoteConfig = {
|
||||
asset_update_options_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
subscription_tiers_enabled?: boolean
|
||||
stripe_publishable_key?: string
|
||||
stripe_pricing_table_id?: string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
@@ -33,6 +35,8 @@ export function useSettingUI(
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
@@ -102,6 +106,12 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowPlanCreditsPanel = computed(() => {
|
||||
if (!subscriptionPanel) return false
|
||||
if (!flags.subscriptionTiersEnabled) return true
|
||||
return isActiveSubscription.value
|
||||
})
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'user',
|
||||
@@ -154,9 +164,7 @@ export function useSettingUI(
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : []),
|
||||
...(isCloud &&
|
||||
window.__CONFIG__?.subscription_required &&
|
||||
subscriptionPanel
|
||||
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
||||
? [subscriptionPanel]
|
||||
: [])
|
||||
].filter((panel) => panel.component)
|
||||
@@ -191,8 +199,7 @@ export function useSettingUI(
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required &&
|
||||
shouldShowPlanCreditsPanel.value &&
|
||||
subscriptionPanel
|
||||
? [subscriptionPanel.node]
|
||||
: []),
|
||||
|
||||
9
src/vite-env.d.ts
vendored
9
src/vite-env.d.ts
vendored
@@ -16,6 +16,15 @@ declare global {
|
||||
interface Window {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
|
||||
readonly VITE_STRIPE_PRICING_TABLE_ID?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
|
||||
|
||||
const mockLoadStripeScript = vi.fn()
|
||||
let currentConfig = {
|
||||
publishableKey: 'pk_test_123',
|
||||
pricingTableId: 'prctbl_123'
|
||||
}
|
||||
let hasConfig = true
|
||||
|
||||
vi.mock('@/config/stripePricingTableConfig', () => ({
|
||||
getStripePricingTableConfig: () => currentConfig,
|
||||
hasStripePricingTableConfig: () => hasConfig
|
||||
}))
|
||||
|
||||
const mockIsLoaded = ref(false)
|
||||
const mockIsLoading = ref(false)
|
||||
const mockError = ref(null)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useStripePricingTableLoader',
|
||||
() => ({
|
||||
useStripePricingTableLoader: () => ({
|
||||
loadScript: mockLoadStripeScript,
|
||||
isLoaded: mockIsLoaded,
|
||||
isLoading: mockIsLoading,
|
||||
error: mockError
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(StripePricingTable, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('StripePricingTable', () => {
|
||||
beforeEach(() => {
|
||||
currentConfig = {
|
||||
publishableKey: 'pk_test_123',
|
||||
pricingTableId: 'prctbl_123'
|
||||
}
|
||||
hasConfig = true
|
||||
mockLoadStripeScript.mockReset().mockResolvedValue(undefined)
|
||||
mockIsLoaded.value = false
|
||||
mockIsLoading.value = false
|
||||
mockError.value = null
|
||||
})
|
||||
|
||||
it('renders the Stripe pricing table when config is available', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLoadStripeScript).toHaveBeenCalled()
|
||||
|
||||
const stripePricingTable = wrapper.find('stripe-pricing-table')
|
||||
expect(stripePricingTable.exists()).toBe(true)
|
||||
expect(stripePricingTable.attributes('publishable-key')).toBe('pk_test_123')
|
||||
expect(stripePricingTable.attributes('pricing-table-id')).toBe('prctbl_123')
|
||||
})
|
||||
|
||||
it('shows missing config message when credentials are absent', () => {
|
||||
hasConfig = false
|
||||
currentConfig = { publishableKey: '', pricingTableId: '' }
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="stripe-table-missing-config"]').exists()
|
||||
).toBe(true)
|
||||
expect(mockLoadStripeScript).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows loading indicator when script is loading', async () => {
|
||||
// Mock loadScript to never resolve, simulating loading state
|
||||
mockLoadStripeScript.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="stripe-table-loading"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error indicator when script fails to load', async () => {
|
||||
// Mock loadScript to reject, simulating error state
|
||||
mockLoadStripeScript.mockRejectedValue(new Error('Script failed to load'))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="stripe-table-error"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user