[backport cloud/1.34] feat: update subscription panel with tier-based design and improved UX (#7312)

Backport of #7307 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7312-backport-cloud-1-34-feat-update-subscription-panel-with-tier-based-design-and-improved-2c56d73d365081e28400d30170266e85)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2025-12-10 14:30:12 +09:00
committed by GitHub
parent ab74061bc6
commit fdda9cc752
7 changed files with 199 additions and 77 deletions

View File

@@ -1,5 +1,6 @@
<template> <template>
<TabPanel value="Credits" class="credits-container h-full"> <TabPanel value="Credits" class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold"> <h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }} {{ $t('credits.credits') }}

View File

@@ -1869,7 +1869,7 @@
"comfyCloud": "Comfy Cloud", "comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo", "comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA", "beta": "BETA",
"perMonth": "USD / month", "perMonth": "/ month",
"renewsDate": "Renews {date}", "renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}", "refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}", "expiresDate": "Expires {date}",
@@ -1884,6 +1884,10 @@
"monthlyBonusDescription": "Monthly credit bonus", "monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits", "prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.", "prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining for this month",
"creditsYouveAdded": "Credits you've added",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle", "nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:", "yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details", "viewMoreDetails": "View more details",
@@ -1894,6 +1898,55 @@
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed", "benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job" "benefit2": "Up to 30 min runtime per job"
}, },
"tiers": {
"founder": {
"name": "Founder's Edition Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever"
}
},
"standard": {
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "164"
}
},
"creator": {
"name": "Creator",
"price": "35.00",
"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100 monthly credits",
"maxDuration": "1 hr max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "821"
}
}
},
"required": { "required": {
"title": "Subscribe to", "title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!", "waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",

View File

@@ -1,42 +1,19 @@
<template> <template>
<div class="flex flex-col items-start gap-1 self-stretch"> <div class="flex flex-col items-start gap-0 self-stretch">
<div class="flex items-start gap-2"> <div class="flex items-center gap-2 py-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" /> <i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary"> <span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }} {{ $t('subscription.benefits.benefit1') }}
</span> </span>
</div> </div>
<div class="flex items-start gap-2"> <div class="flex items-center gap-2 py-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" /> <i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary"> <span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit2') }} {{ $t('subscription.benefits.benefit2') }}
</span> </span>
</div> </div>
<Button
:label="$t('subscription.viewMoreDetails')"
text
icon="pi pi-external-link"
icon-pos="left"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
@click="handleViewMoreDetails"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts"></script>
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
}
</script>

View File

@@ -19,9 +19,12 @@
<div class="rounded-2xl border border-interface-stroke p-6"> <div class="rounded-2xl border border-interface-stroke p-6">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ tierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold"> <div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span> <span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{ <span class="text-base">{{
$t('subscription.perMonth') $t('subscription.perMonth')
}}</span> }}</span>
@@ -59,7 +62,7 @@
class: 'text-text-primary' class: 'text-text-primary'
} }
}" }"
@click="manageSubscription" @click="showSubscriptionDialog"
/> />
<SubscribeButton <SubscribeButton
v-else v-else
@@ -75,17 +78,6 @@
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1"> <div class="flex flex-col flex-1">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
</div>
</div>
</div>
<div <div
:class=" :class="
cn( cn(
@@ -112,7 +104,7 @@
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary"> <div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }} {{ $t('subscription.totalCredits') }}
</div> </div>
<Skeleton <Skeleton
@@ -133,12 +125,18 @@
width="3rem" width="3rem"
height="1rem" height="1rem"
/> />
<div v-else class="text-sm text-text-secondary font-bold"> <div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ monthlyBonusCredits }} {{ monthlyBonusCredits }}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1 min-w-0">
<div class="text-sm text-text-secondary"> <div
{{ $t('subscription.monthlyBonusDescription') }} class="text-sm truncate text-muted"
:title="$t('subscription.creditsRemainingThisMonth')"
>
{{ $t('subscription.creditsRemainingThisMonth') }}
</div> </div>
<Button <Button
v-tooltip="refreshTooltip" v-tooltip="refreshTooltip"
@@ -146,7 +144,7 @@
text text
rounded rounded
size="small" size="small"
class="h-4 w-4" class="h-4 w-4 shrink-0"
:pt="{ :pt="{
icon: { icon: {
class: 'text-text-secondary text-xs' class: 'text-text-secondary text-xs'
@@ -161,12 +159,18 @@
width="3rem" width="3rem"
height="1rem" height="1rem"
/> />
<div v-else class="text-sm text-text-secondary font-bold"> <div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ prepaidCredits }} {{ prepaidCredits }}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1 min-w-0">
<div class="text-sm text-text-secondary"> <div
{{ $t('subscription.prepaidDescription') }} class="text-sm truncate text-muted"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</div> </div>
<Button <Button
v-tooltip="$t('subscription.prepaidCreditsInfo')" v-tooltip="$t('subscription.prepaidCreditsInfo')"
@@ -174,7 +178,7 @@
text text
rounded rounded
size="small" size="small"
class="h-4 w-4" class="h-4 w-4 shrink-0"
:pt="{ :pt="{
icon: { icon: {
class: 'text-text-secondary text-xs' class: 'text-text-secondary text-xs'
@@ -190,8 +194,7 @@
href="https://platform.comfy.org/profile/usage" href="https://platform.comfy.org/profile/usage"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-sm text-text-secondary underline hover:text-text-secondary" class="text-sm underline text-center text-muted"
style="text-decoration: underline"
> >
{{ $t('subscription.viewUsageHistory') }} {{ $t('subscription.viewUsageHistory') }}
</a> </a>
@@ -216,14 +219,47 @@
</div> </div>
<div class="flex flex-col gap-2 flex-1"> <div class="flex flex-col gap-2 flex-1">
<div class="text-sm"> <div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }} {{ $t('subscription.yourPlanIncludes') }}
</div> </div>
<SubscriptionBenefits /> <div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div> </div>
<div <div
@@ -307,28 +343,79 @@
import Button from 'primevue/button' import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CloudBadge from '@/components/topbar/CloudBadge.vue' import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useExternalLink } from '@/composables/useExternalLink' import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.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 { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions' import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits' import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const { buildDocsUrl } = useExternalLink() const { buildDocsUrl } = useExternalLink()
const { t } = useI18n()
const { const {
isActiveSubscription, isActiveSubscription,
isCancelled, isCancelled,
formattedRenewalDate, formattedRenewalDate,
formattedEndDate, formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
handleInvoiceHistory handleInvoiceHistory
} = useSubscription() } = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
// Tier data - hardcoded for Creator tier as requested
const tierName = computed(() => t('subscription.tiers.creator.name'))
const tierPrice = computed(() => t('subscription.tiers.creator.price'))
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed(() => {
const baseBenefits: Benefit[] = [
{
key: 'monthlyCredits',
type: 'metric',
value: t('subscription.tiers.creator.benefits.monthlyCredits'),
label: t('subscription.tiers.creator.benefits.monthlyCreditsLabel')
},
{
key: 'maxDuration',
type: 'metric',
value: t('subscription.tiers.creator.benefits.maxDuration'),
label: t('subscription.tiers.creator.benefits.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.tiers.creator.benefits.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.tiers.creator.benefits.addCreditsLabel')
},
{
key: 'customLoRAs',
type: 'feature',
label: t('subscription.tiers.creator.benefits.customLoRAsLabel')
}
]
return baseBenefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } = const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits() useSubscriptionCredits()

View File

@@ -9,16 +9,20 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
*/ */
export function useSubscriptionCredits() { export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore() const authStore = useFirebaseAuthStore()
const { t, locale } = useI18n() const { locale } = useI18n()
const formatBalance = (maybeCents?: number) => { const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention. // Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0 const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({ const amount = formatCreditsFromCents({
cents, cents,
locale: locale.value locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
}) })
return `${amount} ${t('credits.credits')}` return amount
} }
const totalCredits = computed(() => const totalCredits = computed(() =>

View File

@@ -85,7 +85,7 @@ export function useSettingUI(
children: [] children: []
}, },
component: defineAsyncComponent( component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue') () => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
) )
} }

View File

@@ -84,22 +84,22 @@ describe('useSubscriptionCredits', () => {
}) })
describe('totalCredits', () => { describe('totalCredits', () => {
it('should return "0.00 Credits" when balance is null', () => { it('should return "0" when balance is null', () => {
authStore.balance = null authStore.balance = null
const { totalCredits } = useSubscriptionCredits() const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00 Credits') expect(totalCredits.value).toBe('0')
}) })
it('should return "0.00 Credits" when amount_micros is missing', () => { it('should return "0" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits() const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00 Credits') expect(totalCredits.value).toBe('0')
}) })
it('should format amount_micros correctly', () => { it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits() const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('211.00 Credits') expect(totalCredits.value).toBe('211')
}) })
it('should handle formatting errors by throwing', async () => { it('should handle formatting errors by throwing', async () => {
@@ -116,10 +116,10 @@ describe('useSubscriptionCredits', () => {
}) })
describe('monthlyBonusCredits', () => { describe('monthlyBonusCredits', () => {
it('should return "0.00 Credits" when cloud_credit_balance_micros is missing', () => { it('should return "0" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits() const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00 Credits') expect(monthlyBonusCredits.value).toBe('0')
}) })
it('should format cloud_credit_balance_micros correctly', () => { it('should format cloud_credit_balance_micros correctly', () => {
@@ -127,15 +127,15 @@ describe('useSubscriptionCredits', () => {
cloud_credit_balance_micros: 200 cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse } as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits() const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('422.00 Credits') expect(monthlyBonusCredits.value).toBe('422')
}) })
}) })
describe('prepaidCredits', () => { describe('prepaidCredits', () => {
it('should return "0.00 Credits" when prepaid_balance_micros is missing', () => { it('should return "0" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits() const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00 Credits') expect(prepaidCredits.value).toBe('0')
}) })
it('should format prepaid_balance_micros correctly', () => { it('should format prepaid_balance_micros correctly', () => {
@@ -143,7 +143,7 @@ describe('useSubscriptionCredits', () => {
prepaid_balance_micros: 300 prepaid_balance_micros: 300
} as GetCustomerBalanceResponse } as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits() const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('633.00 Credits') expect(prepaidCredits.value).toBe('633')
}) })
}) })