Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 199 additions & 120 deletions src/components/dialog/content/TopUpCreditsDialogContent.vue
Original file line number Diff line number Diff line change
@@ -1,180 +1,259 @@
<template>
<div class="flex w-112 flex-col gap-8 p-8">
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
Comment on lines +14 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use common button component and add aria-label for accessibility.

The close button is implemented as a plain <button> element. Per coding guidelines, prefer the repo's common button components (e.g., IconButton from src/components/button/) for consistency with the design system.

Additionally, icon-only buttons require an aria-label to provide an accessible name for screen readers.

♻️ Proposed refactor using IconButton

Import IconButton at the top of the script:

+import IconButton from '@/components/ui/button/IconButton.vue'

Then replace the button in the template:

-      <button
-        class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
-        @click="() => handleClose()"
-      >
-        <i class="icon-[lucide--x] size-6" />
-      </button>
+      <IconButton
+        icon="lucide--x"
+        variant="muted-textonly"
+        size="icon-sm"
+        :aria-label="$t('g.close')"
+        @click="handleClose"
+      />

Based on learnings and coding guidelines.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/components/dialog/content/TopUpCreditsDialogContent.vue around lines 14
- 19, The close button in TopUpCreditsDialogContent.vue should use the repo's
common IconButton component and include an accessible name; import IconButton
from src/components/button/IconButton and replace the raw <button> that calls
handleClose() with the IconButton, passing the same click handler (handleClose)
and adding an aria-label like "Close" (or a localized equivalent) while keeping
the icon element (icon-[lucide--x]) as the child so styling and behavior remain
consistent with the design system.

</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>

<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</div>
</div>

<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
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 group-hover:text-base-foreground"
@click="togglePopover"

<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
{{ t('subscription.videoTemplateBasedCredits') }}
</span>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>

<!-- Buy Button -->
<!-- Warnings -->

<p
v-if="isBelowMin"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center"
>
{{
$t('credits.topUp.minimumPurchase', {
amount: MIN_AMOUNT,
credits: usdToCredits(MIN_AMOUNT)
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center"
>
{{
$t('credits.topUp.maximumAmount', {
amount: formatNumber(MAX_AMOUNT)
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
Comment on lines +117 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the useExternalLink composable
fd -t f "useExternalLink" src/

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 144


🏁 Script executed:

#!/bin/bash
# Examine the useExternalLink composable
cat -n src/composables/useExternalLink.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3958


🏁 Script executed:

#!/bin/bash
# Also check the component file around the mentioned lines
sed -n '110,125p' src/components/dialog/content/TopUpCreditsDialogContent.vue

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 485


Consider centralizing the enterprise link URL in the external link composable.

The enterprise contact URL is hardcoded. To maintain consistency with other external links in the component and the codebase, add this URL to the staticUrls object in the useExternalLink composable (alongside discord, github, forum, etc.), then reference it here.

🤖 Prompt for AI Agents
In @src/components/dialog/content/TopUpCreditsDialogContent.vue around lines 117
- 122, TopUpCreditsDialogContent.vue currently hardcodes the enterprise URL; add
a new entry (e.g. enterprise: 'https://www.comfy.org/cloud/enterprise') to the
staticUrls object in the useExternalLink composable and expose it via the
composable's API, then replace the hardcoded href in
TopUpCreditsDialogContent.vue with the composable lookup (e.g.
externalLink('enterprise') or the returned enterpriseUrl) so the component uses
the centralized staticUrls value.

</p>

<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!selectedCredits || loading"
:disabled="!isValidAmount || loading"
:loading="loading"
variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('credits.topUp.buy') }}
{{ $t('credits.topUp.buyCredits') }}
</Button>
</div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<div class="flex items-center justify-center gap-1">
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
:href="pricingUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</Popover>
</div>
</div>
</template>

<script setup lang="ts">
import { Popover } from 'primevue'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'

import CreditTopUpOption from './credit/CreditTopUpOption.vue'

interface CreditOption {
credits: number
description: string
}

const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()

const { formattedRenewalDate } = useSubscription()

const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()

const selectedCredits = ref<number | null>(null)
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000

// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)

const popover = ref()
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)

const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})

const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)

const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)

// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}

// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}

function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}

const togglePopover = (event: Event) => {
popover.value.toggle(event)
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}

const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 30 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 60 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 120 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 301 })
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}

function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
]
dialogStore.closeDialog({ key: 'top-up-credits' })
}

const handleBuy = async () => {
if (!selectedCredits.value) return
async function handleBuy() {
// Prevent double-clicks
if (loading.value || !isValidAmount.value) return

loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
await authActions.purchaseCredits(payAmount.value)

// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
handleClose(false)
dialogService.showSettingsDialog('subscription')
} catch (error) {
console.error('Purchase failed:', error)

Expand Down
Loading
Loading