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
125 changes: 125 additions & 0 deletions src/base/credits/comfyCredits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}

const formatNumber = ({
value,
locale,
options
}: {
value: number
locale?: string
options?: Intl.NumberFormatOptions
}): string => {
const merged: Intl.NumberFormatOptions = {
...DEFAULT_NUMBER_FORMAT,
...options
}

if (
typeof merged.maximumFractionDigits === 'number' &&
typeof merged.minimumFractionDigits === 'number' &&
merged.maximumFractionDigits < merged.minimumFractionDigits
) {
merged.minimumFractionDigits = merged.maximumFractionDigits
}

return new Intl.NumberFormat(locale, merged).format(value)
}

export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent

export const usdToCents = (usd: number): number => Math.round(usd * 100)

export const centsToCredits = (cents: number): number =>
Math.round(cents * COMFY_CREDIT_RATE_CENTS)

export const creditsToCents = (credits: number): number =>
Math.round(credits / COMFY_CREDIT_RATE_CENTS)

export const usdToCredits = (usd: number): number =>
Math.round(usd * CREDITS_PER_USD)

export const creditsToUsd = (credits: number): number =>
Math.round((credits / CREDITS_PER_USD) * 100) / 100

export type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}

export type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}

export type FormatFromUsdOptions = {
usd: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}

export const formatCredits = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({ value, locale, options: numberOptions })

export const formatCreditsFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatCredits({
value: centsToCredits(cents),
locale,
numberOptions
})

export const formatCreditsFromUsd = ({
usd,
locale,
numberOptions
}: FormatFromUsdOptions): string =>
formatCredits({
value: usdToCredits(usd),
locale,
numberOptions
})

export const formatUsd = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({
value,
locale,
options: numberOptions
})

export const formatUsdFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatUsd({
value: cents / 100,
locale,
numberOptions
})

/**
* Clamps a USD value to the allowed range for credit purchases
* @param value - The USD amount to clamp
* @returns The clamped value between $1 and $1000, or 0 if NaN
*/
export const clampUsd = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(1000, Math.max(1, value))
}
13 changes: 10 additions & 3 deletions src/components/common/UserCredit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'

const { textClass } = defineProps<{
textClass?: string
Expand All @@ -38,9 +39,15 @@ const { textClass } = defineProps<{
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()

const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return `${amount} ${t('credits.credits')}`
})
</script>
150 changes: 147 additions & 3 deletions src/components/dialog/content/TopUpCreditsDialogContent.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,65 @@
<template>
<div class="flex w-96 flex-col gap-10 p-2">
<!-- New Credits Design (default) -->
<div
v-if="useNewDesign"
class="flex w-96 flex-col gap-8 p-8 bg-node-component-surface rounded-2xl border border-border-primary"
>
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-foreground-primary m-0">
{{ $t('credits.topUp.addMoreCredits') }}
</h1>
<p class="text-sm text-foreground-secondary m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>

<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" />
<span class="text-sm text-foreground-secondary">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="refreshDate" class="text-sm text-foreground-secondary">
{{ $t('credits.refreshes', { date: refreshDate }) }}
</div>
</div>

<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-foreground-secondary">
{{ $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"
/>
</div>
<div class="text-xs text-foreground-secondary">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>

<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
class="w-full"
@click="handleBuy"
/>
</div>

<!-- Legacy Design -->
<div v-else class="flex w-96 flex-col gap-10 p-2">
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
<h1 class="my-0 text-2xl leading-normal font-medium">
{{ $t('credits.topUp.insufficientTitle') }}
Expand Down Expand Up @@ -34,38 +94,122 @@
>{{ $t('credits.topUp.quickPurchase') }}:</span
>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<CreditTopUpOption
<LegacyCreditTopUpOption
v-for="amount in amountOptions"
:key="amount"
:amount="amount"
:preselected="amount === preselectedAmountOption"
/>

<CreditTopUpOption :amount="100" :preselected="false" editable />
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
</div>
</div>
</div>
</template>

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

import {
creditsToUsd,
formatCredits,
formatUsd
} from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'

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

interface CreditOption {
credits: number
description: string
}

const {
refreshDate,
isInsufficientCredits = false,
amountOptions = [5, 10, 20, 50],
preselectedAmountOption = 10
} = defineProps<{
refreshDate?: string
isInsufficientCredits?: boolean
amountOptions?: number[]
preselectedAmountOption?: number
}>()

const { flags } = useFeatureFlags()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)

const { t, locale } = useI18n()
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()

const selectedCredits = ref<number | null>(null)
const loading = ref(false)

const creditOptions: CreditOption[] = [
{
credits: 1000,
description: t('credits.topUp.videosEstimate', { count: 100 })
},
{
credits: 5000,
description: t('credits.topUp.videosEstimate', { count: 500 })
},
{
credits: 10000,
description: t('credits.topUp.videosEstimate', { count: 1000 })
},
{
credits: 20000,
description: t('credits.topUp.videosEstimate', { count: 2000 })
}
]

const handleBuy = async () => {
if (!selectedCredits.value) return

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

toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
detail: t('credits.topUp.purchaseSuccessDetail', {
credits: formatCredits({
value: selectedCredits.value,
locale: locale.value
}),
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
}),
life: 3000
})
} catch (error) {
console.error('Purchase failed:', error)

const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}

const handleSeeDetails = async () => {
await authActions.accessBillingPortal()
Expand Down
Loading