mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 15:10:06 +00:00
[backport cloud/1.34] feat: replace Stripe pricing table with custom implementation (#7361)
Backport of #7359 to `cloud/1.34` Automatically created by backport workflow. Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1870,6 +1870,7 @@
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "/ month",
|
||||
"usdPerMonth": "USD / month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
@@ -1922,7 +1923,8 @@
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs"
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimate": "120"
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
@@ -1935,7 +1937,8 @@
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs"
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimate": "288"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
@@ -1948,7 +1951,8 @@
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs"
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimate": "815"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1971,7 +1975,29 @@
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "view enterprise",
|
||||
"partnerNodesCredits": "Partner nodes pricing"
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"mostPopular": "Most popular",
|
||||
"currentPlan": "Current Plan",
|
||||
"subscribeTo": "Subscribe to {plan}",
|
||||
"monthlyCreditsLabel": "Monthly credits",
|
||||
"maxDurationLabel": "Max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan Fun Control template",
|
||||
"videoEstimateHelp": "What is this?",
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"credits": {
|
||||
"standard": "4,200",
|
||||
"creator": "7,400",
|
||||
"pro": "21,100"
|
||||
},
|
||||
"maxDuration": {
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
|
||||
273
src/platform/cloud/subscription/components/PricingTable.vue
Normal file
273
src/platform/cloud/subscription/components/PricingTable.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-stretch gap-6">
|
||||
<div
|
||||
v-for="tier in tiers"
|
||||
:key="tier.id"
|
||||
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-8">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="font-inter text-base font-bold leading-normal text-base-foreground">
|
||||
{{ tier.name }}
|
||||
</span>
|
||||
<div
|
||||
v-if="tier.isPopular"
|
||||
class="rounded-full bg-white px-1 text-xs font-semibold uppercase tracking-wide text-black h-[13px] leading-[13px]"
|
||||
>
|
||||
{{ t('subscription.mostPopular') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<span class="font-inter text-[32px] font-semibold leading-normal text-base-foreground">
|
||||
${{ tier.price }}
|
||||
</span>
|
||||
<span class="font-inter text-base font-normal leading-normal text-base-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="font-inter text-sm font-normal leading-normal text-muted-foreground">
|
||||
{{ t('subscription.monthlyCreditsLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span class="font-inter text-sm font-bold leading-normal text-base-foreground">
|
||||
{{ tier.credits }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span class="font-inter text-sm font-bold leading-normal text-base-foreground">
|
||||
{{ tier.maxDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i v-if="tier.customLoRAs" class="pi pi-check text-xs text-white" />
|
||||
<i v-else class="pi pi-times text-xs text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.videoEstimateLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 opacity-50">
|
||||
<i class="pi pi-question-circle text-xs text-muted-foreground" />
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.videoEstimateHelp') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-inter text-sm font-bold leading-normal text-base-foreground">
|
||||
{{ tier.videoEstimate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col p-8">
|
||||
<Button
|
||||
:label="getButtonLabel(tier)"
|
||||
:severity="getButtonSeverity(tier)"
|
||||
:disabled="isLoading || isCurrentPlan(tier.key)"
|
||||
:loading="loadingTier === tier.key"
|
||||
class="h-10 w-full"
|
||||
:pt="{
|
||||
label: {
|
||||
class: 'font-inter text-sm font-bold leading-normal text-white'
|
||||
}
|
||||
}"
|
||||
@click="() => handleSubscribe(tier.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from '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 { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
type TierKey = 'standard' | 'creator' | 'pro'
|
||||
|
||||
interface PricingTierConfig {
|
||||
id: SubscriptionTier
|
||||
key: TierKey
|
||||
name: string
|
||||
price: string
|
||||
credits: string
|
||||
maxDuration: string
|
||||
customLoRAs: boolean
|
||||
videoEstimate: string
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDERS_EDITION: 'standard'
|
||||
}
|
||||
|
||||
const tiers: PricingTierConfig[] = [
|
||||
{
|
||||
id: 'STANDARD',
|
||||
key: 'standard',
|
||||
name: t('subscription.tiers.standard.name'),
|
||||
price: t('subscription.tiers.standard.price'),
|
||||
credits: t('subscription.credits.standard'),
|
||||
maxDuration: t('subscription.maxDuration.standard'),
|
||||
customLoRAs: false,
|
||||
videoEstimate: t('subscription.tiers.standard.benefits.videoEstimate'),
|
||||
isPopular: false
|
||||
},
|
||||
{
|
||||
id: 'CREATOR',
|
||||
key: 'creator',
|
||||
name: t('subscription.tiers.creator.name'),
|
||||
price: t('subscription.tiers.creator.price'),
|
||||
credits: t('subscription.credits.creator'),
|
||||
maxDuration: t('subscription.maxDuration.creator'),
|
||||
customLoRAs: true,
|
||||
videoEstimate: t('subscription.tiers.creator.benefits.videoEstimate'),
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
id: 'PRO',
|
||||
key: 'pro',
|
||||
name: t('subscription.tiers.pro.name'),
|
||||
price: t('subscription.tiers.pro.price'),
|
||||
credits: t('subscription.credits.pro'),
|
||||
maxDuration: t('subscription.maxDuration.pro'),
|
||||
customLoRAs: true,
|
||||
videoEstimate: t('subscription.tiers.pro.benefits.videoEstimate'),
|
||||
isPopular: false
|
||||
}
|
||||
]
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { isActiveSubscription, subscriptionTier } = useSubscription()
|
||||
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const loadingTier = ref<TierKey | null>(null)
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
|
||||
)
|
||||
|
||||
const isCurrentPlan = (tierKey: TierKey): boolean =>
|
||||
currentTierKey.value === tierKey
|
||||
|
||||
const getButtonLabel = (tier: PricingTierConfig): string => {
|
||||
if (isCurrentPlan(tier.key)) return t('subscription.currentPlan')
|
||||
if (!isActiveSubscription.value) return t('subscription.subscribeTo', { plan: tier.name })
|
||||
return t('subscription.changeTo', { plan: tier.name })
|
||||
}
|
||||
|
||||
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
|
||||
isCurrentPlan(tier.key) ? 'secondary' : tier.key === 'creator' ? 'primary' : 'secondary'
|
||||
|
||||
const initiateCheckout = async (tierKey: TierKey) => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
|
||||
{
|
||||
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: TierKey) => {
|
||||
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
|
||||
|
||||
isLoading.value = true
|
||||
loadingTier.value = tierKey
|
||||
|
||||
try {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal()
|
||||
} else {
|
||||
const response = await initiateCheckout(tierKey)
|
||||
if (response.checkout_url) {
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
loadingTier.value = null
|
||||
}
|
||||
},
|
||||
reportError
|
||||
)
|
||||
</script>
|
||||
@@ -1,117 +0,0 @@
|
||||
<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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showStripePricingTable"
|
||||
v-if="showCustomPricingTable"
|
||||
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
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StripePricingTable class="flex-1" />
|
||||
<PricingTable class="flex-1" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center">
|
||||
@@ -138,9 +138,8 @@ import Button from 'primevue/button'
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.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'
|
||||
@@ -168,25 +167,18 @@ const formattedMonthlyPrice = new Intl.NumberFormat(
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(MONTHLY_SUBSCRIPTION_PRICE)
|
||||
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
|
||||
// Always show custom pricing table for cloud subscriptions
|
||||
const showCustomPricingTable = computed(
|
||||
() => isCloud && window.__CONFIG__?.subscription_required
|
||||
)
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
|
||||
const MAX_POLL_ATTEMPTS = 3
|
||||
let pollInterval: number | null = null
|
||||
let pollStartTime = 0
|
||||
let pollAttempts = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
@@ -197,35 +189,44 @@ const stopPolling = () => {
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollStartTime = Date.now()
|
||||
pollAttempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
pollAttempts++
|
||||
|
||||
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
if (showCustomPricingTable.value) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
showStripePricingTable,
|
||||
showCustomPricingTable,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
startPolling()
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
} else {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
@@ -235,7 +236,7 @@ watch(
|
||||
watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showStripePricingTable.value) {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
@@ -270,6 +271,7 @@ const handleViewEnterprise = () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -242,6 +242,7 @@ function useSubscriptionInternal() {
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
|
||||
// Actions
|
||||
subscribe,
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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