change credits icons and tooltips (conditional on feature flag) (#7276)

This PR changes the credits icons and tooltips based on state of the
`subscription_tiers_enabled` feature flag.

When the flag is enabled (or undefined -- for local), the dollar icon is
replaced with the lucide-component icon in UserCredit and node price
badges (Partner Nodes), and a new tooltip row appears in
CurrentUserPopover displaying "Credits have been unified" with a
detailed hover tooltip explaining the credit unification across Partner
Nodes and Cloud workflows.

<img width="539" height="535" alt="image"
src="https://github.com/user-attachments/assets/7e952f9b-0abb-4979-85b7-0eecdeaf808c"
/>

Related:

- https://github.com/Comfy-Org/ComfyUI_frontend/pull/6115 (borrows badge
implementation from this PR)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7276-change-credits-icons-and-tooltips-conditional-on-feature-flag-2c46d73d365081809a6afd5861018a15)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2025-12-09 01:41:32 -08:00
committed by GitHub
parent 4d3f918e8e
commit d3e9e15f07
9 changed files with 165 additions and 55 deletions

View File

@@ -7,12 +7,17 @@
<Skeleton width="8rem" height="2rem" />
</div>
<div v-else class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<Tag severity="secondary" rounded class="p-1 text-amber-400">
<template #icon>
<i
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'pi pi-dollar'
"
/>
</template>
</Tag>
<div :class="textClass">{{ formattedBalance }}</div>
</div>
</template>
@@ -22,6 +27,7 @@ import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
@@ -30,6 +36,7 @@ const { textClass } = defineProps<{
}>()
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const formattedBalance = computed(() => {

View File

@@ -26,6 +26,22 @@
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<div
v-if="flags.subscriptionTiersEnabled"
class="flex items-center gap-2"
>
<i
v-tooltip="{
value: $t('credits.unified.tooltip'),
showDelay: 300,
hideDelay: 300
}"
class="icon-[lucide--circle-help] text-muted cursor-help text-xs"
/>
<span class="text-xs text-muted">{{
$t('credits.unified.message')
}}</span>
</div>
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
@@ -102,6 +118,7 @@ import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
@@ -123,6 +140,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { flags } = useFeatureFlags()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')

View File

@@ -1,10 +1,16 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
const componentIconSvg = new Image()
componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const { flags } = useFeatureFlags()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
@@ -33,34 +39,54 @@ export const usePriceBadge = () => {
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
return (
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
)
const badgeInstance = typeof badge === 'function' ? badge() : badge
if (flags.subscriptionTiersEnabled) {
return badgeInstance.icon?.image === componentIconSvg
} else {
return badgeInstance.icon?.unicode === '\ue96b'
}
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
if (flags.subscriptionTiersEnabled) {
return new LGraphBadge({
text: price,
iconOptions: {
image: componentIconSvg,
size: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
} else {
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
}
return {
getCreditsBadge,

View File

@@ -12,7 +12,8 @@ export enum ServerFeatureFlag {
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled'
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled'
}
/**
@@ -55,6 +56,16 @@ export function useFeatureFlags() {
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
)
},
get subscriptionTiersEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.subscription_tiers_enabled ??
api.getServerFeature(
ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED,
false
)
)
}
})

View File

@@ -66,8 +66,12 @@ export class LGraphBadge {
const { font } = ctx
let iconWidth = 0
if (this.icon) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
if (this.icon.image) {
iconWidth = this.icon.size + this.padding
} else if (this.icon.unicode) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
}
}
ctx.font = `${this.fontSize}px sans-serif`
const textWidth = this.text ? ctx.measureText(this.text).width : 0
@@ -104,7 +108,8 @@ export class LGraphBadge {
// Draw icon if present
if (this.icon) {
this.icon.draw(ctx, drawX, centerY)
drawX += this.icon.fontSize + this.padding / 2 + 4
const iconWidth = this.icon.image ? this.icon.size : this.icon.fontSize
drawX += iconWidth + this.padding / 2 + 4
}
// Draw badge text

View File

@@ -1,20 +1,24 @@
export interface LGraphIconOptions {
unicode: string
unicode?: string
fontFamily?: string
image?: HTMLImageElement
color?: string
bgColor?: string
fontSize?: number
size?: number
circlePadding?: number
xOffset?: number
yOffset?: number
}
export class LGraphIcon {
unicode: string
unicode?: string
fontFamily: string
image?: HTMLImageElement
color: string
bgColor?: string
fontSize: number
size: number
circlePadding: number
xOffset: number
yOffset: number
@@ -22,18 +26,22 @@ export class LGraphIcon {
constructor({
unicode,
fontFamily = 'PrimeIcons',
image,
color = '#e6c200',
bgColor,
fontSize = 16,
size,
circlePadding = 2,
xOffset = 0,
yOffset = 0
}: LGraphIconOptions) {
this.unicode = unicode
this.fontFamily = fontFamily
this.image = image
this.color = color
this.bgColor = bgColor
this.fontSize = fontSize
this.size = size ?? fontSize
this.circlePadding = circlePadding
this.xOffset = xOffset
this.yOffset = yOffset
@@ -43,26 +51,44 @@ export class LGraphIcon {
x += this.xOffset
y += this.yOffset
const { font, textBaseline, textAlign, fillStyle } = ctx
if (this.image) {
const iconSize = this.size
const iconRadius = iconSize / 2 + this.circlePadding
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
// Draw icon background circle if bgColor is set
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
if (this.bgColor) {
const { fillStyle } = ctx
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
ctx.fillStyle = fillStyle
}
const imageX = x + this.circlePadding
const imageY = y - iconSize / 2
ctx.drawImage(this.image, imageX, imageY, iconSize, iconSize)
} else if (this.unicode) {
const { font, textBaseline, textAlign, fillStyle } = ctx
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
}
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
// Draw icon
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
}

View File

@@ -1852,7 +1852,11 @@
"additionalInfo": "Additional Info",
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized"
"accountInitialized": "Account initialized",
"unified": {
"message": "Credits have been unified",
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits.\nLearn more here."
}
},
"subscription": {
"title": "Subscription",

View File

@@ -37,4 +37,5 @@ export type RemoteConfig = {
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
}

View File

@@ -38,7 +38,16 @@
</IconButton>
</div>
<div v-if="isApiNode" class="icon-[lucide--dollar-sign] size-4" />
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div
v-if="isApiNode"
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'icon-[lucide--dollar-sign]'
"
class="size-4"
/>
<!-- Node Title -->
<div
@@ -98,6 +107,7 @@ import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -128,6 +138,8 @@ const emit = defineEmits<{
'enter-subgraph': []
}>()
const { flags } = useFeatureFlags()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()