feat: replace Stripe pricing table with custom implementation

- Replace StripePricingTable with CustomPricingTable component
- Add intelligent subscription tier detection and button logic
- Remove Stripe dependencies and feature flags
- Update subscription dialog to use custom component

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
bymyself
2025-12-11 00:12:30 -08:00
parent b6efc52bf8
commit 65a6cca6d7
8 changed files with 302 additions and 406 deletions

View File

@@ -0,0 +1,260 @@
<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: 'creator'
}
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: '288',
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) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorData.message || 'Failed to initiate checkout'
})
)
}
return 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>

View File

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

View File

@@ -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,31 +189,33 @@ 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)
}
watch(
showStripePricingTable,
showCustomPricingTable,
(enabled) => {
if (enabled) {
startPolling()
@@ -235,7 +229,7 @@ watch(
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showStripePricingTable.value) {
if (isActive && showCustomPricingTable.value) {
emit('close', true)
}
}