add shared comfy credit conversion helpers (#7061)

Introduces cents<->usd<->credit converters plus basic formatters and
adds test. Lays groundwork to start converting UI components into
displaying comfy credits.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7061-add-shared-comfy-credit-conversion-helpers-2bb6d73d3650810bb34fdf9bb3fc115b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-12-09 04:11:27 -08:00
committed by GitHub
parent 8209f5a108
commit aef40834f3
14 changed files with 628 additions and 159 deletions

View File

@@ -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
@@ -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>

View File

@@ -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') }}
@@ -34,14 +94,14 @@
>{{ $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>
@@ -49,23 +109,107 @@
<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()

View File

@@ -1,81 +1,43 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<InputNumber
v-if="editable"
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
pt:pc-input-text:root="w-24"
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
/>
<span v-else class="text-xl">{{ amount }}</span>
<div
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'bg-surface-secondary border-2 border-primary'
: 'bg-surface-tertiary border border-border-primary hover:bg-surface-secondary'
]"
@click="$emit('select')"
>
<div class="flex flex-col">
<span class="text-base font-medium text-foreground-primary">
{{ formattedCredits }}
</span>
<span class="text-sm text-foreground-secondary">
{{ description }}
</span>
</div>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
import { formatCredits } from '@/base/credits/comfyCredits'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
const { credits, description, selected } = defineProps<{
credits: number
description: string
selected: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
defineEmits<{
select: []
}>()
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
const { locale } = useI18n()
loading.value = true
await authActions.purchaseCredits(creditAmount)
loading.value = false
didClickBuyNow.value = true
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
const formattedCredits = computed(() => {
return formatCredits({ value: credits, locale: locale.value })
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
<div v-if="editable" class="flex items-center gap-2">
<InputNumber
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
prefix="$"
pt:pc-input-text:root="w-28"
@blur="
(e: InputNumberBlurEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
@input="
(e: InputNumberInputEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
/>
<span class="text-xs text-muted">{{ formattedCredits }}</span>
</div>
<div v-else class="flex flex-col leading-tight">
<span class="text-xl font-semibold">{{ formattedCredits }}</span>
<span class="text-xs text-muted">{{ formattedUsd }}</span>
</div>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
clampUsd,
formatCreditsFromUsd,
formatUsd
} from '@/base/credits/comfyCredits'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
const { t, locale } = useI18n()
const displayUsdAmount = computed(() =>
editable ? clampUsd(Number(customAmount.value)) : clampUsd(amount)
)
const formattedCredits = computed(
() =>
`${formatCreditsFromUsd({
usd: displayUsdAmount.value,
locale: locale.value
})} ${t('credits.credits')}`
)
const formattedUsd = computed(
() => `$${formatUsd({ value: displayUsdAmount.value, locale: locale.value })}`
)
const handleBuyNow = async () => {
const creditAmount = displayUsdAmount.value
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
try {
await authActions.purchaseCredits(creditAmount)
didClickBuyNow.value = true
} finally {
loading.value = false
}
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
})
</script>