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
298 changes: 212 additions & 86 deletions src/components/dialog/content/TopUpCreditsDialogContent.vue
Original file line number Diff line number Diff line change
@@ -1,133 +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>
</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="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}

<!-- 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"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>

<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
@click="handleBuy"
<!-- Warnings -->

<p
v-if="isBelowMin"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center"
>
{{ $t('credits.topUp.buy') }}
</Button>
{{
$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
>
</p>

<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading"
:loading="loading"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('credits.topUp.buyCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>

<script setup lang="ts">
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 creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
// 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 handleBuy = async () => {
if (!selectedCredits.value) return
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))
}

// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}

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' })
}

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