mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-03 04:00:31 +00:00
feat: add Free subscription tier support (#8864)
## Summary Add frontend support for a Free subscription tier — login/signup page restructuring, telemetry instrumentation, and tier-aware billing gating. ## Changes - **What**: - Restructure login/signup pages: OAuth buttons promoted as primary sign-in method, email login available via progressive disclosure - Add Free tier badge on Google sign-up button with dynamic credit count from remote config - Add `FREE` subscription tier to type system (tier pricing, tier rank, registry types) - Add `isFreeTier` computed to `useSubscription()` - Disable credit top-up for Free tier users (dialogService, purchaseCredits, popover CTA) - Show subscription/upgrade dialog instead of top-up dialog when Free tier user hits out-of-credits - Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened` with `free_tier_badge_shown`, track email toggle clicks ## Review Focus - Tier gating logic: Free tier users should see "Upgrade" instead of "Add Credits" and never reach the top-up flow - Telemetry event design for Mixpanel funnel analysis - Progressive disclosure UX on login/signup pages ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="relative grid h-full grid-cols-5">
|
||||
<!-- Custom close button -->
|
||||
<Button
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="rounded-full 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="$emit('close', false)"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
|
||||
>
|
||||
<video
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="h-full min-w-[125%] object-cover p-0"
|
||||
style="margin-left: -20%"
|
||||
>
|
||||
<source
|
||||
src="/assets/images/cloud-subscription.webm"
|
||||
type="video/webm"
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex flex-col justify-between p-8">
|
||||
<div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-sm text-text-primary">
|
||||
<template v-if="reason === 'out_of_credits'">
|
||||
{{ $t('subscription.freeTier.outOfCredits.title') }}
|
||||
</template>
|
||||
<template v-else-if="reason === 'top_up_blocked'">
|
||||
{{ $t('subscription.freeTier.topUpBlocked.title') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('subscription.freeTier.title') }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="reason === 'out_of_credits'"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{ $t('subscription.freeTier.outOfCredits.subtitle') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!reason || reason === 'subscription_required'"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
freeTierCredits
|
||||
? $t('subscription.freeTier.description', {
|
||||
credits: freeTierCredits.toLocaleString()
|
||||
})
|
||||
: $t('subscription.freeTier.descriptionGeneric')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="
|
||||
(!reason || reason === 'subscription_required') &&
|
||||
formattedRenewalDate
|
||||
"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
$t('subscription.freeTier.nextRefresh', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SubscriptionBenefits is-free-tier class="mt-6 text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col pt-8">
|
||||
<Button
|
||||
class="w-full rounded-lg bg-[var(--color-accent-blue,#0B8CE9)] px-4 py-2 font-inter text-sm font-bold text-white hover:bg-[var(--color-accent-blue,#0B8CE9)]/90"
|
||||
@click="$emit('upgrade')"
|
||||
>
|
||||
{{
|
||||
reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
? $t('subscription.freeTier.upgradeCta')
|
||||
: $t('subscription.freeTier.subscribeCta')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
defineProps<{
|
||||
reason?: SubscriptionDialogReason
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
upgrade: []
|
||||
}>()
|
||||
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
|
||||
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||
</script>
|
||||
@@ -24,6 +24,7 @@ const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isFreeTier: computed(() => false),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
||||
subscriptionStatus: ref(null)
|
||||
|
||||
@@ -272,7 +272,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
type CheckoutTierKey = Exclude<TierKey, 'founder'>
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
|
||||
|
||||
const getCheckoutTier = (
|
||||
@@ -344,8 +344,12 @@ const tiers: PricingTierConfig[] = [
|
||||
isPopular: false
|
||||
}
|
||||
]
|
||||
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
|
||||
useSubscription()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTier,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
const telemetry = useTelemetry()
|
||||
const { userId } = storeToRefs(useFirebaseAuthStore())
|
||||
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
|
||||
@@ -356,6 +360,10 @@ const loadingTier = ref<CheckoutTierKey | null>(null)
|
||||
const popover = ref()
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
const hasPaidSubscription = computed(
|
||||
() => isActiveSubscription.value && !isFreeTier.value
|
||||
)
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
|
||||
)
|
||||
@@ -392,7 +400,7 @@ const getButtonLabel = (tier: PricingTierConfig): string => {
|
||||
? t('subscription.tierNameYearly', { name: tier.name })
|
||||
: tier.name
|
||||
|
||||
return isActiveSubscription.value
|
||||
return hasPaidSubscription.value
|
||||
? t('subscription.changeTo', { plan: planName })
|
||||
: t('subscription.subscribeTo', { plan: planName })
|
||||
}
|
||||
@@ -427,7 +435,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
loadingTier.value = tierKey
|
||||
|
||||
try {
|
||||
if (isActiveSubscription.value) {
|
||||
if (hasPaidSubscription.value) {
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
|
||||
@@ -24,6 +24,7 @@ import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -46,6 +47,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { subscriptionTier } = useSubscription()
|
||||
const isAwaitingStripeSubscription = ref(false)
|
||||
|
||||
watch(
|
||||
@@ -60,7 +62,9 @@ watch(
|
||||
|
||||
const handleSubscribe = () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked')
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
})
|
||||
}
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit1') }}
|
||||
{{
|
||||
isFreeTier
|
||||
? $t('subscription.benefits.benefit1FreeTier')
|
||||
: $t('subscription.benefits.benefit1')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +17,18 @@
|
||||
{{ $t('subscription.benefits.benefit2') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit3') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
const { isFreeTier = false } = defineProps<{
|
||||
isFreeTier?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
v-if="isActiveSubscription && !isFreeTier"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
@@ -213,9 +213,10 @@ import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
@@ -224,6 +225,7 @@ const { t, n } = useI18n()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
isFreeTier,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
@@ -264,6 +266,7 @@ const creditsRemainingLabel = computed(() =>
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
if (credits === null) return '—'
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
@@ -272,48 +275,9 @@ const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
const tierBenefits = computed((): TierBenefit[] =>
|
||||
getCommonTierBenefits(tierKey.value, t, n)
|
||||
)
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
@@ -81,7 +81,11 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.required.title') }}
|
||||
{{
|
||||
reason === 'out_of_credits'
|
||||
? $t('credits.topUp.insufficientTitle')
|
||||
: $t('subscription.required.title')
|
||||
}}
|
||||
</div>
|
||||
<CloudBadge
|
||||
reverse-order
|
||||
@@ -91,6 +95,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="reason === 'out_of_credits'"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-bold">{{ formattedMonthlyPrice }}</span>
|
||||
<span class="text-xl">{{ $t('subscription.perMonth') }}</span>
|
||||
@@ -131,9 +142,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
const props = defineProps<{
|
||||
const { onClose, reason } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -234,7 +247,7 @@ const handleSubscribed = () => {
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
props.onClose()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleContactUs = async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
@@ -77,6 +78,8 @@ function useSubscriptionInternal() {
|
||||
() => subscriptionStatus.value?.subscription_tier ?? null
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
|
||||
|
||||
const subscriptionDuration = computed(
|
||||
() => subscriptionStatus.value?.subscription_duration ?? null
|
||||
)
|
||||
@@ -130,12 +133,17 @@ function useSubscriptionInternal() {
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = () => {
|
||||
const showSubscriptionDialog = (options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('modal_opened')
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
reason: options?.reason
|
||||
})
|
||||
}
|
||||
|
||||
void showSubscriptionRequiredDialog()
|
||||
void showSubscriptionRequiredDialog(options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,6 +286,7 @@ function useSubscriptionInternal() {
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
isFreeTier,
|
||||
subscriptionDuration,
|
||||
isYearlySubscription,
|
||||
subscriptionTierName,
|
||||
|
||||
@@ -2,21 +2,30 @@ import { defineAsyncComponent } from 'vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
|
||||
export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
|
||||
export const useSubscriptionDialog = () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isFreeTier } = useSubscription()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
|
||||
const useWorkspaceVariant =
|
||||
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
|
||||
|
||||
@@ -34,7 +43,8 @@ export const useSubscriptionDialog = () => {
|
||||
key: DIALOG_KEY,
|
||||
component,
|
||||
props: {
|
||||
onClose: hide
|
||||
onClose: hide,
|
||||
reason: options?.reason
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
@@ -51,8 +61,46 @@ export const useSubscriptionDialog = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function show(options?: { reason?: SubscriptionDialogReason }) {
|
||||
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
|
||||
const component = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
|
||||
)
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: FREE_TIER_DIALOG_KEY,
|
||||
component,
|
||||
props: {
|
||||
reason: options?.reason,
|
||||
onClose: hide,
|
||||
onUpgrade: () => {
|
||||
hide()
|
||||
showPricingTable(options)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(640px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showPricingTable(options)
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
showPricingTable,
|
||||
hide
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
|
||||
export type TierKey = 'standard' | 'creator' | 'pro' | 'founder'
|
||||
export type TierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder'
|
||||
|
||||
export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
FREE: 'free',
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
@@ -12,6 +14,7 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
}
|
||||
|
||||
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
|
||||
free: 'FREE',
|
||||
standard: 'STANDARD',
|
||||
creator: 'CREATOR',
|
||||
pro: 'PRO',
|
||||
@@ -25,7 +28,10 @@ export interface TierPricing {
|
||||
videoEstimate: number
|
||||
}
|
||||
|
||||
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
|
||||
export const TIER_PRICING: Record<
|
||||
Exclude<TierKey, 'free' | 'founder'>,
|
||||
TierPricing
|
||||
> = {
|
||||
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 380 },
|
||||
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 670 },
|
||||
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 1915 }
|
||||
@@ -37,6 +43,7 @@ interface TierFeatures {
|
||||
}
|
||||
|
||||
const TIER_FEATURES: Record<TierKey, TierFeatures> = {
|
||||
free: { customLoRAs: false, maxMembers: 1 },
|
||||
standard: { customLoRAs: false, maxMembers: 1 },
|
||||
creator: { customLoRAs: true, maxMembers: 5 },
|
||||
pro: { customLoRAs: true, maxMembers: 20 },
|
||||
@@ -49,12 +56,14 @@ const FOUNDER_MONTHLY_PRICE = 20
|
||||
const FOUNDER_MONTHLY_CREDITS = 5460
|
||||
|
||||
export function getTierPrice(tierKey: TierKey, isYearly = false): number {
|
||||
if (tierKey === 'free') return 0
|
||||
if (tierKey === 'founder') return FOUNDER_MONTHLY_PRICE
|
||||
const pricing = TIER_PRICING[tierKey]
|
||||
return isYearly ? pricing.yearly : pricing.monthly
|
||||
}
|
||||
|
||||
export function getTierCredits(tierKey: TierKey): number {
|
||||
export function getTierCredits(tierKey: TierKey): number | null {
|
||||
if (tierKey === 'free') return remoteConfig.value.free_tier_credits ?? null
|
||||
if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS
|
||||
return TIER_PRICING[tierKey].credits
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricin
|
||||
|
||||
export type BillingCycle = 'monthly' | 'yearly'
|
||||
|
||||
type RankedTierKey = Exclude<TierKey, 'founder'>
|
||||
type RankedTierKey = Exclude<TierKey, 'founder' | 'free'>
|
||||
type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
|
||||
|
||||
interface PlanDescriptor {
|
||||
@@ -28,7 +28,7 @@ const toRankedPlanKey = (
|
||||
tierKey: TierKey,
|
||||
billingCycle: BillingCycle
|
||||
): RankedPlanKey | null => {
|
||||
if (tierKey === 'founder') return null
|
||||
if (tierKey === 'founder' || tierKey === 'free') return null
|
||||
return `${billingCycle}-${tierKey}` as RankedPlanKey
|
||||
}
|
||||
|
||||
|
||||
67
src/platform/cloud/subscription/utils/tierBenefits.ts
Normal file
67
src/platform/cloud/subscription/utils/tierBenefits.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
getTierCredits,
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
type BenefitType = 'metric' | 'feature' | 'icon'
|
||||
|
||||
export interface TierBenefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export function getCommonTierBenefits(
|
||||
key: TierKey,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
n: (value: number) => string
|
||||
): TierBenefit[] {
|
||||
const benefits: TierBenefit[] = []
|
||||
const isFree = key === 'free'
|
||||
|
||||
if (isFree) {
|
||||
const credits = getTierCredits(key)
|
||||
if (credits !== null) {
|
||||
benefits.push({
|
||||
key: 'monthlyCredits',
|
||||
type: 'metric',
|
||||
value: n(credits),
|
||||
label: t('subscription.monthlyCreditsLabel')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
benefits.push({
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
})
|
||||
|
||||
benefits.push({
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
})
|
||||
|
||||
if (!isFree) {
|
||||
benefits.push({
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
}
|
||||
Reference in New Issue
Block a user