mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Feat(cloud)/new top up dialog (#7899)
## Summary - Implement the new add credits (top up) dialog. - Refactor the subscription dialog to make different credit types easier to understand ## Changes - **What**: TopUpCreditsDialogContent.vue, SubscriptionPanel.vue, /en/main.json - **Breaking**: <!-- Any breaking changes (if none, remove this line) --> - **Dependencies**: <!-- New dependencies (if none, remove this line) --> ## Review Focus <!-- Critical design decisions or edge cases that need attention --> <!-- If this PR fixes an issue, uncomment and update the line below --> <!-- Fixes #ISSUE_NUMBER --> https://github.com/user-attachments/assets/a6454651-e195-4430-bfcc-0f2a8c1dc80b Relevant notion links: https://www.notion.so/comfy-org/Implement-New-Top-Up-Dialog-with-Custom-Amount-Input-2df6d73d36508142b901fc0edb0d1fc1?source=copy_link https://www.notion.so/comfy-org/Implement-Update-confusing-credits-remaining-this-month-message-2df6d73d36508168b7e5ed46754cec60?source=copy_link
This commit is contained in:
@@ -1,180 +1,259 @@
|
|||||||
<template>
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex py-8 items-center justify-between px-8">
|
||||||
<h1 class="text-2xl font-semibold text-base-foreground m-0">
|
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||||
{{
|
{{
|
||||||
isInsufficientCredits
|
isInsufficientCredits
|
||||||
? $t('credits.topUp.addMoreCreditsToRun')
|
? $t('credits.topUp.addMoreCreditsToRun')
|
||||||
: $t('credits.topUp.addMoreCredits')
|
: $t('credits.topUp.addMoreCredits')
|
||||||
}}
|
}}
|
||||||
</h1>
|
</h2>
|
||||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
|
<button
|
||||||
<p class="text-sm text-muted-foreground m-0 w-96">
|
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"
|
||||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
@click="() => handleClose()"
|
||||||
</p>
|
>
|
||||||
</div>
|
<i class="icon-[lucide--x] size-6" />
|
||||||
<div v-else class="flex flex-col gap-2">
|
</button>
|
||||||
<p class="text-sm text-muted-foreground m-0">
|
|
||||||
{{ $t('credits.topUp.creditsDescription') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="isInsufficientCredits"
|
||||||
|
class="text-sm text-muted-foreground m-0 px-8"
|
||||||
|
>
|
||||||
|
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Current Balance Section -->
|
<!-- Preset amount buttons -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="px-8">
|
||||||
<div class="flex items-baseline gap-2">
|
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||||
<UserCredit text-class="text-3xl font-bold" show-credits-only />
|
{{ $t('credits.topUp.selectAmount') }}
|
||||||
<span class="text-sm text-muted-foreground">{{
|
</h3>
|
||||||
$t('credits.creditsAvailable')
|
<div class="flex gap-2 pt-3">
|
||||||
}}</span>
|
<Button
|
||||||
</div>
|
v-for="amount in PRESET_AMOUNTS"
|
||||||
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
|
:key="amount"
|
||||||
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
|
:autofocus="amount === 50"
|
||||||
</div>
|
variant="secondary"
|
||||||
</div>
|
size="lg"
|
||||||
|
:class="
|
||||||
<!-- Credit Options Section -->
|
cn(
|
||||||
<div class="flex flex-col gap-4">
|
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||||
<span class="text-sm text-muted-foreground">
|
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||||
{{ $t('credits.topUp.howManyCredits') }}
|
)
|
||||||
</span>
|
"
|
||||||
<div class="flex flex-col gap-2">
|
@click="handlePresetClick(amount)"
|
||||||
<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="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"
|
|
||||||
>
|
>
|
||||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
${{ amount }}
|
||||||
</span>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
<!-- Buy Button -->
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||||
<Button
|
<Button
|
||||||
:disabled="!selectedCredits || loading"
|
:disabled="!isValidAmount || loading"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
size="lg"
|
||||||
|
class="h-10 justify-center"
|
||||||
@click="handleBuy"
|
@click="handleBuy"
|
||||||
>
|
>
|
||||||
{{ $t('credits.topUp.buy') }}
|
{{ $t('credits.topUp.buyCredits') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<div class="flex items-center justify-center gap-1">
|
||||||
<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>
|
|
||||||
<a
|
<a
|
||||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
:href="pricingUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
|
||||||
>
|
>
|
||||||
<span class="underline">
|
{{ $t('credits.topUp.viewPricing') }}
|
||||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
<i class="icon-[lucide--external-link] size-4" />
|
||||||
</span>
|
|
||||||
<span class="no-underline" v-html="'→'"></span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Popover } from 'primevue'
|
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { creditsToUsd } from '@/base/credits/comfyCredits'
|
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||||
import UserCredit from '@/components/common/UserCredit.vue'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
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 { 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 { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
|
||||||
|
|
||||||
interface CreditOption {
|
|
||||||
credits: number
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isInsufficientCredits = false } = defineProps<{
|
const { isInsufficientCredits = false } = defineProps<{
|
||||||
isInsufficientCredits?: boolean
|
isInsufficientCredits?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { formattedRenewalDate } = useSubscription()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
const dialogService = useDialogService()
|
||||||
const telemetry = useTelemetry()
|
const telemetry = useTelemetry()
|
||||||
const toast = useToast()
|
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 loading = ref(false)
|
||||||
|
|
||||||
const popover = ref()
|
// Computed
|
||||||
|
const pricingUrl = computed(() =>
|
||||||
|
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||||
|
)
|
||||||
|
|
||||||
const togglePopover = (event: Event) => {
|
const creditsModel = computed({
|
||||||
popover.value.toggle(event)
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
const creditOptions: CreditOption[] = [
|
// Step amount functions
|
||||||
{
|
function getStepAmount(currentAmount: number): number {
|
||||||
credits: 1055, // $5.00
|
if (currentAmount < 100) return 5
|
||||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
if (currentAmount < 1000) return 50
|
||||||
},
|
return 100
|
||||||
{
|
}
|
||||||
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 })
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleBuy = async () => {
|
function getCreditsStepAmount(currentCredits: number): number {
|
||||||
if (!selectedCredits.value) return
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const usdAmount = creditsToUsd(selectedCredits.value)
|
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
|
await authActions.purchaseCredits(payAmount.value)
|
||||||
await authActions.purchaseCredits(usdAmount)
|
|
||||||
|
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
|
||||||
|
handleClose(false)
|
||||||
|
dialogService.showSettingsDialog('subscription')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Purchase failed:', error)
|
console.error('Purchase failed:', error)
|
||||||
|
|
||||||
|
|||||||
176
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
176
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex h-10 items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover',
|
||||||
|
disabled && 'opacity-50 pointer-events-none'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||||
|
:disabled="disabled || modelValue <= min"
|
||||||
|
:aria-label="$t('g.decrement')"
|
||||||
|
@click="handleStep(-1)"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--minus] size-4" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
|
||||||
|
>
|
||||||
|
<slot name="prefix" />
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="inputValue"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
:style="{ width: `${inputWidth}ch` }"
|
||||||
|
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="handleInputChange"
|
||||||
|
@blur="handleInputBlur"
|
||||||
|
@focus="handleInputFocus"
|
||||||
|
/>
|
||||||
|
<slot name="suffix" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||||
|
:disabled="disabled || modelValue >= max"
|
||||||
|
:aria-label="$t('g.increment')"
|
||||||
|
@click="handleStep(1)"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--plus] size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const {
|
||||||
|
min = 0,
|
||||||
|
max = Infinity,
|
||||||
|
step = 1,
|
||||||
|
formatOptions = { useGrouping: true },
|
||||||
|
disabled = false
|
||||||
|
} = defineProps<{
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number | ((value: number) => number)
|
||||||
|
formatOptions?: Intl.NumberFormatOptions
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'max-reached': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<number>({ required: true })
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const inputValue = ref(formatNumber(modelValue.value))
|
||||||
|
|
||||||
|
const inputWidth = computed(() =>
|
||||||
|
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(modelValue, (newValue) => {
|
||||||
|
if (document.activeElement !== inputRef.value) {
|
||||||
|
inputValue.value = formatNumber(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return num.toLocaleString('en-US', formatOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFormattedNumber(str: string): number {
|
||||||
|
const cleaned = str.replace(/[^0-9]/g, '')
|
||||||
|
return cleaned === '' ? 0 : parseInt(cleaned, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minVal: number, maxVal: number): number {
|
||||||
|
return Math.min(Math.max(value, minVal), maxVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWithCursor(
|
||||||
|
value: string,
|
||||||
|
cursorPos: number
|
||||||
|
): { formatted: string; newCursor: number } {
|
||||||
|
const num = parseFormattedNumber(value)
|
||||||
|
const formatted = formatNumber(num)
|
||||||
|
|
||||||
|
const digitsBeforeCursor = value
|
||||||
|
.slice(0, cursorPos)
|
||||||
|
.replace(/[^0-9]/g, '').length
|
||||||
|
|
||||||
|
let digitCount = 0
|
||||||
|
let newCursor = 0
|
||||||
|
for (let i = 0; i < formatted.length; i++) {
|
||||||
|
if (/[0-9]/.test(formatted[i])) {
|
||||||
|
digitCount++
|
||||||
|
}
|
||||||
|
if (digitCount >= digitsBeforeCursor) {
|
||||||
|
newCursor = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digitCount < digitsBeforeCursor) {
|
||||||
|
newCursor = formatted.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return { formatted, newCursor }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStepAmount(): number {
|
||||||
|
return typeof step === 'function' ? step(modelValue.value) : step
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const raw = input.value
|
||||||
|
const cursorPos = input.selectionStart ?? raw.length
|
||||||
|
const num = parseFormattedNumber(raw)
|
||||||
|
|
||||||
|
const clamped = Math.min(num, max)
|
||||||
|
const wasClamped = num > max
|
||||||
|
|
||||||
|
if (wasClamped) {
|
||||||
|
emit('max-reached')
|
||||||
|
}
|
||||||
|
|
||||||
|
modelValue.value = clamped
|
||||||
|
|
||||||
|
const { formatted, newCursor } = formatWithCursor(
|
||||||
|
wasClamped ? formatNumber(clamped) : raw,
|
||||||
|
wasClamped ? formatNumber(clamped).length : cursorPos
|
||||||
|
)
|
||||||
|
inputValue.value = formatted
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.value?.setSelectionRange(newCursor, newCursor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputBlur() {
|
||||||
|
const clamped = clamp(modelValue.value, min, max)
|
||||||
|
modelValue.value = clamped
|
||||||
|
inputValue.value = formatNumber(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputFocus(e: FocusEvent) {
|
||||||
|
;(e.target as HTMLInputElement).select()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStep(direction: 1 | -1) {
|
||||||
|
const stepAmount = getStepAmount()
|
||||||
|
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
|
||||||
|
modelValue.value = newValue
|
||||||
|
inputValue.value = formatNumber(newValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -10,8 +10,10 @@
|
|||||||
"downloadVideo": "Download video",
|
"downloadVideo": "Download video",
|
||||||
"editOrMaskImage": "Edit or mask image",
|
"editOrMaskImage": "Edit or mask image",
|
||||||
"editImage": "Edit image",
|
"editImage": "Edit image",
|
||||||
|
"decrement": "Decrement",
|
||||||
"deleteImage": "Delete image",
|
"deleteImage": "Delete image",
|
||||||
"deleteAudioFile": "Delete audio file",
|
"deleteAudioFile": "Delete audio file",
|
||||||
|
"increment": "Increment",
|
||||||
"removeImage": "Remove image",
|
"removeImage": "Remove image",
|
||||||
"removeVideo": "Remove video",
|
"removeVideo": "Remove video",
|
||||||
"chart": "Chart",
|
"chart": "Chart",
|
||||||
@@ -1918,12 +1920,25 @@
|
|||||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||||
"howManyCredits": "How many credits would you like to add?",
|
"howManyCredits": "How many credits would you like to add?",
|
||||||
"videosEstimate": "~{count} videos",
|
"usdAmount": "${amount}",
|
||||||
|
"videosEstimate": "~{count} videos*",
|
||||||
"templateNote": "*Generated with Wan Fun Control template",
|
"templateNote": "*Generated with Wan Fun Control template",
|
||||||
"buy": "Buy",
|
"buy": "Buy",
|
||||||
"purchaseError": "Purchase Failed",
|
"purchaseError": "Purchase Failed",
|
||||||
"purchaseErrorDetail": "Failed to purchase credits: {error}",
|
"purchaseErrorDetail": "Failed to purchase credits: {error}",
|
||||||
"unknownError": "An unknown error occurred"
|
"unknownError": "An unknown error occurred",
|
||||||
|
"viewPricing": "View pricing details",
|
||||||
|
"youPay": "Amount (USD)",
|
||||||
|
"youGet": "Credits",
|
||||||
|
"buyCredits": "Continue to payment",
|
||||||
|
"minimumPurchase": "${amount} minimum ({credits} credits)",
|
||||||
|
"maximumAmount": "${amount} max.",
|
||||||
|
"creditsPerDollar": "credits per dollar",
|
||||||
|
"amountToPayLabel": "Amount to pay in dollars",
|
||||||
|
"creditsToReceiveLabel": "Credits to receive",
|
||||||
|
"selectAmount": "Select amount",
|
||||||
|
"needMore": "Need more?",
|
||||||
|
"contactUs": "Contact us"
|
||||||
},
|
},
|
||||||
"creditsAvailable": "Credits available",
|
"creditsAvailable": "Credits available",
|
||||||
"refreshes": "Refreshes {date}",
|
"refreshes": "Refreshes {date}",
|
||||||
@@ -1960,9 +1975,9 @@
|
|||||||
"monthlyBonusDescription": "Monthly credit bonus",
|
"monthlyBonusDescription": "Monthly credit bonus",
|
||||||
"prepaidDescription": "Pre-paid credits",
|
"prepaidDescription": "Pre-paid credits",
|
||||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||||
"creditsRemainingThisMonth": "Credits remaining this month",
|
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||||
"creditsRemainingThisYear": "Credits remaining this year",
|
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||||
"creditsYouveAdded": "Credits you've added",
|
"creditsYouveAdded": "Additional",
|
||||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||||
"nextBillingCycle": "next billing cycle",
|
"nextBillingCycle": "next billing cycle",
|
||||||
@@ -2017,7 +2032,7 @@
|
|||||||
"subscribeTo": "Subscribe to {plan}",
|
"subscribeTo": "Subscribe to {plan}",
|
||||||
"monthlyCreditsLabel": "Monthly credits",
|
"monthlyCreditsLabel": "Monthly credits",
|
||||||
"yearlyCreditsLabel": "Total yearly credits",
|
"yearlyCreditsLabel": "Total yearly credits",
|
||||||
"maxDurationLabel": "Max duration of each workflow run",
|
"maxDurationLabel": "Max run duration",
|
||||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||||
"addCreditsLabel": "Add more credits whenever",
|
"addCreditsLabel": "Add more credits whenever",
|
||||||
"customLoRAsLabel": "Import your own LoRAs",
|
"customLoRAsLabel": "Import your own LoRAs",
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const mockSubscriptionData = {
|
|||||||
const baseName = TIER_TO_NAME[mockSubscriptionTier.value]
|
const baseName = TIER_TO_NAME[mockSubscriptionTier.value]
|
||||||
return mockIsYearlySubscription.value ? `${baseName} Yearly` : baseName
|
return mockIsYearlySubscription.value ? `${baseName} Yearly` : baseName
|
||||||
}),
|
}),
|
||||||
|
subscriptionStatus: computed(() => ({
|
||||||
|
renewal_date: '2024-12-31T00:00:00Z'
|
||||||
|
})),
|
||||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
||||||
handleInvoiceHistory: vi.fn()
|
handleInvoiceHistory: vi.fn()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,8 +84,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
|
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||||
<div class="flex flex-col flex-1">
|
<div class="flex flex-col shrink-0">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
@@ -98,11 +98,11 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="muted-textonly"
|
variant="muted-textonly"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
class="absolute top-0.5 right-0"
|
class="absolute top-4 right-4"
|
||||||
:loading="isLoadingBalance"
|
:loading="isLoadingBalance"
|
||||||
@click="handleRefresh"
|
@click="handleRefresh"
|
||||||
>
|
>
|
||||||
<i class="pi pi-sync text-text-secondary text-xs" />
|
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -120,60 +120,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Credit Breakdown -->
|
<!-- Credit Breakdown -->
|
||||||
<div class="flex flex-col gap-1">
|
<table class="text-sm text-muted">
|
||||||
<div class="flex items-center gap-4">
|
<tbody>
|
||||||
<Skeleton
|
<tr>
|
||||||
v-if="isLoadingBalance"
|
<td class="pr-4 font-bold text-left align-middle">
|
||||||
width="3rem"
|
<Skeleton
|
||||||
height="1rem"
|
v-if="isLoadingBalance"
|
||||||
/>
|
width="5rem"
|
||||||
<div
|
height="1rem"
|
||||||
v-else
|
/>
|
||||||
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
|
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||||
>
|
</td>
|
||||||
{{ monthlyBonusCredits }}
|
<td class="align-middle" :title="creditsRemainingLabel">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 min-w-0">
|
|
||||||
<div
|
|
||||||
class="text-sm truncate text-muted"
|
|
||||||
:title="creditsRemainingLabel"
|
|
||||||
>
|
|
||||||
{{ creditsRemainingLabel }}
|
{{ creditsRemainingLabel }}
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
<tr>
|
||||||
<div class="flex items-center gap-4">
|
<td class="pr-4 font-bold text-left align-middle">
|
||||||
<Skeleton
|
<Skeleton
|
||||||
v-if="isLoadingBalance"
|
v-if="isLoadingBalance"
|
||||||
width="3rem"
|
width="3rem"
|
||||||
height="1rem"
|
height="1rem"
|
||||||
/>
|
/>
|
||||||
<div
|
<span v-else>{{ prepaidCredits }}</span>
|
||||||
v-else
|
</td>
|
||||||
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
|
<td
|
||||||
>
|
class="align-middle"
|
||||||
{{ prepaidCredits }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 min-w-0">
|
|
||||||
<div
|
|
||||||
class="text-sm truncate text-muted"
|
|
||||||
:title="$t('subscription.creditsYouveAdded')"
|
:title="$t('subscription.creditsYouveAdded')"
|
||||||
>
|
>
|
||||||
{{ $t('subscription.creditsYouveAdded') }}
|
{{ $t('subscription.creditsYouveAdded') }}
|
||||||
</div>
|
</td>
|
||||||
<Button
|
</tr>
|
||||||
v-tooltip="$t('subscription.prepaidCreditsInfo')"
|
</tbody>
|
||||||
variant="muted-textonly"
|
</table>
|
||||||
size="icon-sm"
|
|
||||||
class="h-4 w-4 shrink-0 rounded-full"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="pi pi-question-circle text-text-secondary text-xs"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<a
|
<a
|
||||||
@@ -197,7 +176,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 flex-1">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-sm text-text-primary">
|
<div class="text-sm text-text-primary">
|
||||||
{{ $t('subscription.yourPlanIncludes') }}
|
{{ $t('subscription.yourPlanIncludes') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +267,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Skeleton from 'primevue/skeleton'
|
import Skeleton from 'primevue/skeleton'
|
||||||
import TabPanel from 'primevue/tabpanel'
|
import TabPanel from 'primevue/tabpanel'
|
||||||
import { computed } from 'vue'
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||||
@@ -320,6 +299,7 @@ const {
|
|||||||
formattedEndDate,
|
formattedEndDate,
|
||||||
subscriptionTier,
|
subscriptionTier,
|
||||||
subscriptionTierName,
|
subscriptionTierName,
|
||||||
|
subscriptionStatus,
|
||||||
isYearlySubscription,
|
isYearlySubscription,
|
||||||
handleInvoiceHistory
|
handleInvoiceHistory
|
||||||
} = useSubscription()
|
} = useSubscription()
|
||||||
@@ -334,10 +314,34 @@ const tierKey = computed(() => {
|
|||||||
const tierPrice = computed(() =>
|
const tierPrice = computed(() =>
|
||||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const refillsDate = computed(() => {
|
||||||
|
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||||
|
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = String(date.getFullYear()).slice(-2)
|
||||||
|
return `${month}/${day}/${year}`
|
||||||
|
})
|
||||||
|
|
||||||
const creditsRemainingLabel = computed(() =>
|
const creditsRemainingLabel = computed(() =>
|
||||||
isYearlySubscription.value
|
isYearlySubscription.value
|
||||||
? t('subscription.creditsRemainingThisYear')
|
? t('subscription.creditsRemainingThisYear', {
|
||||||
: t('subscription.creditsRemainingThisMonth')
|
date: refillsDate.value
|
||||||
|
})
|
||||||
|
: t('subscription.creditsRemainingThisMonth', {
|
||||||
|
date: refillsDate.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const planTotalCredits = computed(() => {
|
||||||
|
const credits = getTierCredits(tierKey.value)
|
||||||
|
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||||
|
return n(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
const includedCreditsDisplay = computed(
|
||||||
|
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tier benefits for v-for loop
|
// Tier benefits for v-for loop
|
||||||
@@ -354,14 +358,6 @@ const tierBenefits = computed((): Benefit[] => {
|
|||||||
const key = tierKey.value
|
const key = tierKey.value
|
||||||
|
|
||||||
const benefits: Benefit[] = [
|
const benefits: Benefit[] = [
|
||||||
{
|
|
||||||
key: 'monthlyCredits',
|
|
||||||
type: 'metric',
|
|
||||||
value: n(getTierCredits(key)),
|
|
||||||
label: isYearlySubscription.value
|
|
||||||
? t('subscription.yearlyCreditsLabel')
|
|
||||||
: t('subscription.monthlyCreditsLabel')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'maxDuration',
|
key: 'maxDuration',
|
||||||
type: 'metric',
|
type: 'metric',
|
||||||
@@ -402,6 +398,35 @@ const {
|
|||||||
handleLearnMoreClick
|
handleLearnMoreClick
|
||||||
} = useSubscriptionActions()
|
} = useSubscriptionActions()
|
||||||
|
|
||||||
|
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||||
|
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||||
|
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
function handleWindowFocus() {
|
||||||
|
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||||
|
if (!timestampStr) return
|
||||||
|
|
||||||
|
const timestamp = parseInt(timestampStr, 10)
|
||||||
|
|
||||||
|
// Clear expired tracking (older than 5 minutes)
|
||||||
|
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||||
|
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh and clear tracking to prevent repeated calls
|
||||||
|
void handleRefresh()
|
||||||
|
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('focus', handleWindowFocus)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('focus', handleWindowFocus)
|
||||||
|
})
|
||||||
|
|
||||||
const handleOpenPartnerNodesInfo = () => {
|
const handleOpenPartnerNodesInfo = () => {
|
||||||
window.open(
|
window.open(
|
||||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||||
|
|||||||
@@ -368,7 +368,8 @@ export const useDialogService = () => {
|
|||||||
headless: true,
|
headless: true,
|
||||||
pt: {
|
pt: {
|
||||||
header: { class: 'p-0! hidden' },
|
header: { class: 'p-0! hidden' },
|
||||||
content: { class: 'p-0! m-0!' }
|
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||||
|
root: { class: 'rounded-2xl' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user