mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 00:39:49 +00:00
feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7288)
Integrates Stripe's pricing table web component into the subscription dialog when the subscription_tiers_enabled feature flag is active. The implementation includes a new StripePricingTable component that loads Stripe's pricing table script and renders the table with proper error handling and loading states. The subscription dialog now displays the Stripe pricing table with contact us and enterprise links, using a 1100px width that balances multi-column layout with visual design. Configuration supports environment variables, remote config, and window config for the Stripe publishable key and pricing table ID. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7288-feat-add-Stripe-pricing-table-integration-for-subscription-dialog-conditional-on-featur-2c46d73d365081fa9d93c213df118996) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user