mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-06 13:40:25 +00:00
feat: update subscription panel with tier-based design and improved UX (#7307)
Transforms the subscription credits panel from legacy design to tier-based layout with Creator tier details, updated typography using design system tokens, improved responsive credit breakdown layout, and better subscription management flow. Updates credit formatting to remove unnecessary decimals and Credits suffix, replaces external Stripe billing portal with inline dialog, and reorganizes plan benefits section with proper v-for structure matching Figma specifications. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7307-feat-update-subscription-panel-with-tier-based-design-and-improved-UX-2c56d73d365081ef8b63e262a6822c72) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
@@ -1878,7 +1878,7 @@
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"perMonth": "/ month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
@@ -1893,6 +1893,10 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"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",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"viewMoreDetails": "View more details",
|
||||
@@ -1903,6 +1907,55 @@
|
||||
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
|
||||
"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": {
|
||||
"title": "Subscribe to",
|
||||
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<div class="flex flex-col items-start gap-0 self-stretch">
|
||||
<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') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<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.benefit2') }}
|
||||
</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const handleViewMoreDetails = () => {
|
||||
window.open('https://www.comfy.org/cloud/pricing', '_blank')
|
||||
}
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<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">
|
||||
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{
|
||||
$t('subscription.perMonth')
|
||||
}}</span>
|
||||
@@ -59,7 +62,7 @@
|
||||
class: 'text-text-primary'
|
||||
}
|
||||
}"
|
||||
@click="manageSubscription"
|
||||
@click="showSubscriptionDialog"
|
||||
/>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
@@ -75,17 +78,6 @@
|
||||
<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 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
|
||||
:class="
|
||||
cn(
|
||||
@@ -112,7 +104,7 @@
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-secondary">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
@@ -133,12 +125,18 @@
|
||||
width="3rem"
|
||||
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 }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.monthlyBonusDescription') }}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="text-sm truncate text-muted"
|
||||
:title="$t('subscription.creditsRemainingThisMonth')"
|
||||
>
|
||||
{{ $t('subscription.creditsRemainingThisMonth') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="refreshTooltip"
|
||||
@@ -146,7 +144,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
@@ -161,12 +159,18 @@
|
||||
width="3rem"
|
||||
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 }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.prepaidDescription') }}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="text-sm truncate text-muted"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="$t('subscription.prepaidCreditsInfo')"
|
||||
@@ -174,7 +178,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
@@ -190,8 +194,7 @@
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-text-secondary underline hover:text-text-secondary"
|
||||
style="text-decoration: underline"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
@@ -216,14 +219,47 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
@@ -307,28 +343,79 @@
|
||||
import Button from 'primevue/button'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
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 { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
formattedMonthlyPrice,
|
||||
manageSubscription,
|
||||
handleInvoiceHistory
|
||||
} = 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 } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
|
||||
@@ -9,16 +9,20 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
*/
|
||||
export function useSubscriptionCredits() {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { t, locale } = useI18n()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const formatBalance = (maybeCents?: number) => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = maybeCents ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
})
|
||||
return `${amount} ${t('credits.credits')}`
|
||||
return amount
|
||||
}
|
||||
|
||||
const totalCredits = computed(() =>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -84,22 +84,22 @@ describe('useSubscriptionCredits', () => {
|
||||
})
|
||||
|
||||
describe('totalCredits', () => {
|
||||
it('should return "0.00 Credits" when balance is null', () => {
|
||||
it('should return "0" when balance is null', () => {
|
||||
authStore.balance = null
|
||||
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
|
||||
const { totalCredits } = useSubscriptionCredits()
|
||||
expect(totalCredits.value).toBe('0.00 Credits')
|
||||
expect(totalCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should format amount_micros correctly', () => {
|
||||
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
|
||||
const { totalCredits } = useSubscriptionCredits()
|
||||
expect(totalCredits.value).toBe('211.00 Credits')
|
||||
expect(totalCredits.value).toBe('211')
|
||||
})
|
||||
|
||||
it('should handle formatting errors by throwing', async () => {
|
||||
@@ -116,10 +116,10 @@ describe('useSubscriptionCredits', () => {
|
||||
})
|
||||
|
||||
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
|
||||
const { monthlyBonusCredits } = useSubscriptionCredits()
|
||||
expect(monthlyBonusCredits.value).toBe('0.00 Credits')
|
||||
expect(monthlyBonusCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should format cloud_credit_balance_micros correctly', () => {
|
||||
@@ -127,15 +127,15 @@ describe('useSubscriptionCredits', () => {
|
||||
cloud_credit_balance_micros: 200
|
||||
} as GetCustomerBalanceResponse
|
||||
const { monthlyBonusCredits } = useSubscriptionCredits()
|
||||
expect(monthlyBonusCredits.value).toBe('422.00 Credits')
|
||||
expect(monthlyBonusCredits.value).toBe('422')
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
const { prepaidCredits } = useSubscriptionCredits()
|
||||
expect(prepaidCredits.value).toBe('0.00 Credits')
|
||||
expect(prepaidCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should format prepaid_balance_micros correctly', () => {
|
||||
@@ -143,7 +143,7 @@ describe('useSubscriptionCredits', () => {
|
||||
prepaid_balance_micros: 300
|
||||
} as GetCustomerBalanceResponse
|
||||
const { prepaidCredits } = useSubscriptionCredits()
|
||||
expect(prepaidCredits.value).toBe('633.00 Credits')
|
||||
expect(prepaidCredits.value).toBe('633')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user