feat: add ever-present upgrade button for free-tier users (#9315)

## Summary

Add persistent upgrade CTAs for free-tier users: a topbar button and
"Upgrade to add credits" replacing "Add Credits" in popovers and
settings panels.

## Changes

- **What**:
- New `TopbarSubscribeButton` component in both GraphCanvas and
LinearView topbars, visible only to free-tier users
- Profile popover (legacy + workspace): free-tier users see "Upgrade to
add credits" instead of "Add Credits", linking directly to the pricing
table
- Manage Plan settings (legacy + workspace): same replacement —
free-tier users see "Upgrade to add credits" instead of "Add Credits"
- Paid-tier users retain the original "Add Credits" behavior in all
locations
  - All upgrade buttons go directly to the pricing table (one-step flow)

## Review Focus

- The `isFreeTier` conditional gating on the buttons — ensure free-tier
users see upgrade CTAs and paid users see normal Add Credits
- Layout in Manage Plan panels uses `flex flex-col gap-3` to stack the
upgrade button below the usage history link instead of side-by-side

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9315-feat-add-ever-present-upgrade-button-for-free-tier-users-3166d73d365081228cdfe6a67fec6aec)
by [Unito](https://www.unito.io)
This commit is contained in:
Hunter
2026-02-28 23:07:12 -05:00
committed by GitHub
parent 7c8a548798
commit 589f58f916
12 changed files with 118 additions and 30 deletions

View File

@@ -634,6 +634,10 @@
} }
} }
@utility bg-subscription-gradient {
background: var(--color-subscription-button-gradient);
}
@utility highlight { @utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent); background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700; font-weight: 700;

View File

@@ -18,6 +18,7 @@
> >
<WorkflowTabs /> <WorkflowTabs />
<TopbarBadges /> <TopbarBadges />
<TopbarSubscribeButton />
</div> </div>
</div> </div>
</template> </template>
@@ -140,6 +141,7 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue' import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue' import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue' import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue' import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback' import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'

View File

@@ -46,6 +46,16 @@
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto" class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
/> />
<Button <Button
v-if="isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
variant="secondary" variant="secondary"
size="sm" size="sm"
class="text-base-foreground" class="text-base-foreground"
@@ -61,7 +71,7 @@
:fluid="false" :fluid="false"
:label="$t('subscription.subscribeToComfyCloud')" :label="$t('subscription.subscribeToComfyCloud')"
size="sm" size="sm"
variant="gradient" button-variant="gradient"
@subscribed="handleSubscribed" @subscribed="handleSubscribed"
/> />
</div> </div>
@@ -170,6 +180,7 @@ const settingsDialog = useSettingsDialog()
const dialogService = useDialogService() const dialogService = useDialogService()
const { const {
isActiveSubscription, isActiveSubscription,
isFreeTier,
subscriptionTierName, subscriptionTierName,
subscriptionTier, subscriptionTier,
fetchStatus fetchStatus
@@ -237,6 +248,11 @@ const handleOpenPartnerNodesInfo = () => {
emit('close') emit('close')
} }
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
emit('close')
}
const handleLogout = async () => { const handleLogout = async () => {
await handleSignOut() await handleSignOut()
emit('close') emit('close')

View File

@@ -0,0 +1,25 @@
<template>
<Button
v-if="isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"
>
{{ $t('subscription.subscribeForMore') }}
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
}
</script>

View File

@@ -19,7 +19,9 @@ export const buttonVariants = cva({
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover', 'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly': 'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10', 'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90' 'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
gradient:
'bg-subscription-gradient text-white border-transparent hover:opacity-90'
}, },
size: { size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs', sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -47,7 +49,8 @@ const variants = [
'textonly', 'textonly',
'muted-textonly', 'muted-textonly',
'destructive-textonly', 'destructive-textonly',
'overlay-white' 'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']> ] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array< const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size'] ButtonVariants['size']

View File

@@ -2292,6 +2292,8 @@
}, },
"subscribeToRun": "Subscribe", "subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run", "subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
"upgradeToAddCredits": "Upgrade to add credits",
"subscribeNow": "Subscribe Now", "subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud", "subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription", "workspaceNotSubscribed": "This workspace is not on a subscription",

View File

@@ -2,15 +2,7 @@
<Button <Button
:size :size
:disabled="disabled" :disabled="disabled"
variant="primary" :variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
:style="
variant === 'gradient'
? {
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)'
}
: undefined
"
:class="cn('font-bold', fluid && 'w-full')" :class="cn('font-bold', fluid && 'w-full')"
@click="handleSubscribe" @click="handleSubscribe"
> >
@@ -31,13 +23,13 @@ import { cn } from '@/utils/tailwindUtil'
const { const {
size = 'lg', size = 'lg',
fluid = true, fluid = true,
variant = 'default', buttonVariant = 'default',
label, label,
disabled = false disabled = false
} = defineProps<{ } = defineProps<{
label?: string label?: string
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
variant?: 'default' | 'gradient' buttonVariant?: 'default' | 'gradient'
fluid?: boolean fluid?: boolean
disabled?: boolean disabled?: boolean
}>() }>()

View File

@@ -5,13 +5,8 @@
showDelay: 600 showDelay: 600
}" }"
class="subscribe-to-run-button whitespace-nowrap" class="subscribe-to-run-button whitespace-nowrap"
variant="primary" variant="gradient"
size="sm" size="sm"
:style="{
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)',
borderColor: 'transparent'
}"
data-testid="subscribe-to-run-button" data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun" @click="handleSubscribeToRun"
> >

View File

@@ -130,17 +130,25 @@
</tbody> </tbody>
</table> </table>
<div class="flex items-center justify-between"> <div class="flex flex-col gap-3">
<a <a
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 underline text-center text-muted" class="text-sm underline text-muted"
> >
{{ $t('subscription.viewUsageHistory') }} {{ $t('subscription.viewUsageHistory') }}
</a> </a>
<Button <Button
v-if="isActiveSubscription" v-if="isActiveSubscription && isFreeTier"
variant="gradient"
class="p-2 min-h-8 rounded-lg text-sm font-normal w-full"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else-if="isActiveSubscription"
variant="secondary" variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected" class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits" @click="handleAddApiCredits"
@@ -234,7 +242,8 @@ const {
isYearlySubscription isYearlySubscription
} = useSubscription() } = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog() const { show: showSubscriptionDialog, showPricingTable } =
useSubscriptionDialog()
const tierKey = computed(() => { const tierKey = computed(() => {
const tier = subscriptionTier.value const tier = subscriptionTier.value
@@ -296,6 +305,10 @@ const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions() const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
function handleUpgradeToAddCredits() {
showPricingTable()
}
// Focus-based polling: refresh balance when user returns from Stripe checkout // Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp' const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes

View File

@@ -72,9 +72,19 @@
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }" v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground" class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/> />
<!-- Add Credits (subscribed + personal or workspace owner only) --> <!-- Upgrade to add credits (free tier) -->
<Button <Button
v-if="isActiveSubscription && permissions.canTopUp" v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<!-- Add Credits (subscribed + personal or workspace owner only, paid tier) -->
<Button
v-else-if="isActiveSubscription && permissions.canTopUp"
variant="secondary" variant="secondary"
size="sm" size="sm"
class="text-base-foreground" class="text-base-foreground"
@@ -93,7 +103,7 @@
: $t('workspaceSwitcher.subscribe') : $t('workspaceSwitcher.subscribe')
" "
size="sm" size="sm"
variant="gradient" button-variant="gradient"
/> />
<Button <Button
v-if="showSubscribeAction && !isPersonalWorkspace" v-if="showSubscribeAction && !isPersonalWorkspace"
@@ -242,8 +252,14 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser() useCurrentUser()
const settingsDialog = useSettingsDialog() const settingsDialog = useSettingsDialog()
const dialogService = useDialogService() const dialogService = useDialogService()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } = const {
useBillingContext() isActiveSubscription,
isFreeTier,
subscription,
balance,
isLoading,
fetchBalance
} = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false) const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const subscriptionDialog = useSubscriptionDialog() const subscriptionDialog = useSubscriptionDialog()
@@ -310,6 +326,11 @@ const handleOpenPlanAndCreditsSettings = () => {
emit('close') emit('close')
} }
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
emit('close')
}
const handleTopUp = () => { const handleTopUp = () => {
// Track purchase credits entry from avatar popover // Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked() useTelemetry()?.trackAddApiCreditButtonClicked()

View File

@@ -252,9 +252,18 @@
!showZeroState && !showZeroState &&
permissions.canTopUp permissions.canTopUp
" "
class="flex items-center justify-between" class="flex flex-col gap-3"
> >
<Button <Button
v-if="isFreeTierPlan"
variant="gradient"
class="p-2 min-h-8 rounded-lg text-sm font-normal w-full"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
variant="secondary" variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected" class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits" @click="handleAddApiCredits"
@@ -464,6 +473,10 @@ function handleSubscribeWorkspace() {
function handleUpgrade() { function handleUpgrade() {
isFreeTierPlan.value ? showPricingTable() : showSubscriptionDialog() isFreeTierPlan.value ? showPricingTable() : showSubscriptionDialog()
} }
function handleUpgradeToAddCredits() {
showPricingTable()
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null) const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed( const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL' () => subscription.value?.duration === 'ANNUAL'

View File

@@ -11,6 +11,7 @@ import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue' import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue' import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue' import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue' import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue' import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
@@ -82,6 +83,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<WorkflowTabs /> <WorkflowTabs />
<TopbarBadges /> <TopbarBadges />
<TopbarSubscribeButton />
</div> </div>
</div> </div>
<div <div