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:
Christian Byrne
2025-12-09 03:45:45 -08:00
committed by GitHub
parent 77e453db36
commit 8209f5a108
14 changed files with 651 additions and 18 deletions

View File

@@ -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
View File

@@ -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

View 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
}
}

View File

@@ -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",
@@ -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": {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
)

View File

@@ -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 =>

View File

@@ -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
}
})
}

View File

@@ -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
}

View File

@@ -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
View File

@@ -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 {}

View File

@@ -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)
})
})