mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat(cloud): yearly pricing (#7572)
## Summary Add support for yearly/annual billing. Implement new Figma design. Add new yearly tier params to api. ## Changes - **What**: Mostly PricingTable.vue, SubscriptionRequiredDialogContent.vue, - **Breaking**: <!-- Any breaking changes (if none, remove this line) --> - **Dependencies**: <!-- New dependencies (if none, remove this line) --> ## Screenshots (if applicable) https://github.com/user-attachments/assets/06545dca-95a4-43ce-a128-2e45bb44f132 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7572-feat-yearly-pricing-2cb6d73d365081c68802f2beb47a312e) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -235,7 +235,7 @@
|
||||
--brand-yellow: var(--color-electric-400);
|
||||
--brand-blue: var(--color-sapphire-700);
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
|
||||
@@ -1907,7 +1907,7 @@
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "/ month",
|
||||
"usdPerMonth": "USD / month",
|
||||
"usdPerMonth": "USD / mo",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
@@ -1929,16 +1929,21 @@
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"viewMoreDetails": "View more details",
|
||||
"learnMore": "Learn more",
|
||||
"billedMonthly": "Billed monthly",
|
||||
"billedAnnually": "{total} Billed annually",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
"benefits": {
|
||||
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
"tiers": {
|
||||
"founder": {
|
||||
"name": "Founder's Edition",
|
||||
"price": "20.00",
|
||||
"price": "20",
|
||||
"benefits": {
|
||||
"monthlyCredits": "5,460",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
@@ -1951,7 +1956,11 @@
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard",
|
||||
"price": "20.00",
|
||||
"price": {
|
||||
"monthly": "20",
|
||||
"yearly": "16",
|
||||
"annualTotal": "$192"
|
||||
},
|
||||
"benefits": {
|
||||
"monthlyCredits": "4,200",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
@@ -1965,7 +1974,12 @@
|
||||
},
|
||||
"creator": {
|
||||
"name": "Creator",
|
||||
"price": "35.00",
|
||||
"price": {
|
||||
"monthly": "35",
|
||||
"yearly": "28",
|
||||
"annualTotal": "$336"
|
||||
},
|
||||
|
||||
"benefits": {
|
||||
"monthlyCredits": "7,400",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
@@ -1979,7 +1993,11 @@
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "100.00",
|
||||
"price": {
|
||||
"monthly": "100",
|
||||
"yearly": "80",
|
||||
"annualTotal": "$960"
|
||||
},
|
||||
"benefits": {
|
||||
"monthlyCredits": "21,100",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
@@ -2004,7 +2022,7 @@
|
||||
"description": "Choose the best plan for you",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "view enterprise",
|
||||
"viewEnterprise": "View enterprise",
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"mostPopular": "Most popular",
|
||||
"currentPlan": "Current Plan",
|
||||
|
||||
@@ -1,12 +1,56 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-stretch gap-6">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex justify-center">
|
||||
<SelectButton
|
||||
v-model="currentBillingCycle"
|
||||
:options="billingCycleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
|
||||
},
|
||||
pcToggleButton: {
|
||||
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'w-36 h-8 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
|
||||
context.active
|
||||
? 'bg-base-foreground text-base-background'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
|
||||
]
|
||||
}),
|
||||
label: { class: 'flex items-center gap-2 ' }
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.label }}</span>
|
||||
<div
|
||||
v-if="option.value === 'yearly'"
|
||||
class="bg-primary-background text-white text-[11px] px-1 py-0.5 rounded-full flex items-center font-bold"
|
||||
>
|
||||
-20%
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<div class="flex flex-col xl: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)]"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)]',
|
||||
tier.isPopular ? 'border-muted-foreground' : ''
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-8">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="p-8 pb-0 flex flex-col gap-8">
|
||||
<div class="flex flex-row items-center gap-2 justify-between">
|
||||
<span
|
||||
class="font-inter text-base font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
@@ -14,29 +58,49 @@
|
||||
</span>
|
||||
<div
|
||||
v-if="tier.isPopular"
|
||||
class="rounded-full bg-background px-1 text-xs font-semibold uppercase tracking-wide text-foreground h-[13px] leading-[13px]"
|
||||
class="rounded-full bg-base-foreground px-1.5 text-[11px] font-bold uppercase text-base-background h-5 tracking-tight flex items-center"
|
||||
>
|
||||
{{ t('subscription.mostPopular') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-2">
|
||||
<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
|
||||
v-show="currentBillingCycle === 'yearly'"
|
||||
class="line-through text-2xl text-muted-foreground"
|
||||
>
|
||||
${{ tier.price.monthly }}
|
||||
</span>
|
||||
${{ getPrice(tier) }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-base font-normal leading-normal text-base-foreground"
|
||||
class="font-inter text-xl leading-normal text-base-foreground"
|
||||
>
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
currentBillingCycle === 'yearly'
|
||||
? t('subscription.billedAnnually', {
|
||||
total: tier.price.annualTotal
|
||||
})
|
||||
: t('subscription.billedMonthly')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
|
||||
<div class="flex flex-col gap-4 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"
|
||||
class="font-inter text-sm font-normal leading-normal text-foreground"
|
||||
>
|
||||
{{ t('subscription.monthlyCreditsLabel') }}
|
||||
</span>
|
||||
@@ -51,7 +115,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span
|
||||
@@ -62,42 +126,42 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="tier.customLoRAs"
|
||||
class="pi pi-check text-xs text-success-foreground"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-muted-foreground" />
|
||||
<i v-else class="pi pi-times text-xs text-foreground" />
|
||||
</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">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.videoEstimateLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 opacity-50">
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground"
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer hover:text-base-foreground"
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ t('subscription.videoEstimateHelp') }}
|
||||
@@ -112,14 +176,21 @@
|
||||
</div>
|
||||
</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"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 w-full',
|
||||
tier.key === 'creator'
|
||||
? 'bg-base-foreground border-transparent hover:bg-inverted-background-hover'
|
||||
: 'bg-secondary-background border-transparent hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:pt="{
|
||||
label: {
|
||||
class: getButtonTextClass(tier)
|
||||
@@ -161,11 +232,15 @@
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
@@ -182,12 +257,25 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
type TierKey = 'standard' | 'creator' | 'pro'
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
|
||||
type BillingCycle = 'monthly' | 'yearly'
|
||||
|
||||
const getCheckoutTier = (
|
||||
tierKey: TierKey,
|
||||
billingCycle: BillingCycle
|
||||
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
|
||||
|
||||
interface BillingCycleOption {
|
||||
label: string
|
||||
value: BillingCycle
|
||||
}
|
||||
|
||||
interface PricingTierConfig {
|
||||
id: SubscriptionTier
|
||||
key: TierKey
|
||||
name: string
|
||||
price: string
|
||||
price: Record<BillingCycle, string> & { annualTotal: string }
|
||||
credits: string
|
||||
maxDuration: string
|
||||
customLoRAs: boolean
|
||||
@@ -195,6 +283,11 @@ interface PricingTierConfig {
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const billingCycleOptions: BillingCycleOption[] = [
|
||||
{ label: t('subscription.yearly'), value: 'yearly' },
|
||||
{ label: t('subscription.monthly'), value: 'monthly' }
|
||||
]
|
||||
|
||||
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
@@ -207,7 +300,11 @@ const tiers: PricingTierConfig[] = [
|
||||
id: 'STANDARD',
|
||||
key: 'standard',
|
||||
name: t('subscription.tiers.standard.name'),
|
||||
price: t('subscription.tiers.standard.price'),
|
||||
price: {
|
||||
monthly: t('subscription.tiers.standard.price.monthly'),
|
||||
yearly: t('subscription.tiers.standard.price.yearly'),
|
||||
annualTotal: t('subscription.tiers.standard.price.annualTotal')
|
||||
},
|
||||
credits: t('subscription.credits.standard'),
|
||||
maxDuration: t('subscription.maxDuration.standard'),
|
||||
customLoRAs: false,
|
||||
@@ -218,7 +315,11 @@ const tiers: PricingTierConfig[] = [
|
||||
id: 'CREATOR',
|
||||
key: 'creator',
|
||||
name: t('subscription.tiers.creator.name'),
|
||||
price: t('subscription.tiers.creator.price'),
|
||||
price: {
|
||||
monthly: t('subscription.tiers.creator.price.monthly'),
|
||||
yearly: t('subscription.tiers.creator.price.yearly'),
|
||||
annualTotal: t('subscription.tiers.creator.price.annualTotal')
|
||||
},
|
||||
credits: t('subscription.credits.creator'),
|
||||
maxDuration: t('subscription.maxDuration.creator'),
|
||||
customLoRAs: true,
|
||||
@@ -229,7 +330,11 @@ const tiers: PricingTierConfig[] = [
|
||||
id: 'PRO',
|
||||
key: 'pro',
|
||||
name: t('subscription.tiers.pro.name'),
|
||||
price: t('subscription.tiers.pro.price'),
|
||||
price: {
|
||||
monthly: t('subscription.tiers.pro.price.monthly'),
|
||||
yearly: t('subscription.tiers.pro.price.yearly'),
|
||||
annualTotal: t('subscription.tiers.pro.price.annualTotal')
|
||||
},
|
||||
credits: t('subscription.credits.pro'),
|
||||
maxDuration: t('subscription.maxDuration.pro'),
|
||||
customLoRAs: true,
|
||||
@@ -246,6 +351,7 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const isLoading = ref(false)
|
||||
const loadingTier = ref<TierKey | null>(null)
|
||||
const popover = ref()
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
|
||||
@@ -274,17 +380,21 @@ const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
|
||||
|
||||
const getButtonTextClass = (tier: PricingTierConfig): string =>
|
||||
tier.key === 'creator'
|
||||
? 'font-inter text-sm font-bold leading-normal text-white'
|
||||
? 'font-inter text-sm font-bold leading-normal text-base-background'
|
||||
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'
|
||||
|
||||
const getPrice = (tier: PricingTierConfig): string =>
|
||||
tier.price[currentBillingCycle.value]
|
||||
|
||||
const initiateCheckout = async (tierKey: TierKey) => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
|
||||
const response = await fetch(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' }
|
||||
|
||||
@@ -1,45 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
class="relative flex flex-col p-4 pt-8 md:p-16 !overflow-y-auto h-full gap-8"
|
||||
>
|
||||
<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
|
||||
:pt="{
|
||||
icon: { class: 'text-xl' }
|
||||
}"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
|
||||
class="shrink-0 text-text-secondary hover:bg-white/10 absolute right-2.5 top-2.5"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0">
|
||||
{{ $t('subscription.description') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<PricingTable class="flex-1" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-sm text-text-secondary">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class="text-sm text-text-secondary m-0">
|
||||
{{ $t('subscription.haveQuestions') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
:label="$t('subscription.contactUs')"
|
||||
text
|
||||
@@ -95,7 +83,7 @@
|
||||
<div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="text-sm text-muted text-text-primary">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.required.title') }}
|
||||
</div>
|
||||
<CloudBadge
|
||||
|
||||
@@ -23,13 +23,14 @@ export const useSubscriptionDialog = () => {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(1200px, 95vw); max-height: 90vh;',
|
||||
style: 'width: min(1328px, 95vw); max-height: 90vh;',
|
||||
pt: {
|
||||
root: {
|
||||
class: '!rounded-[32px] overflow-visible'
|
||||
class: 'rounded-2xl bg-transparent'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
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)]'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user