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:
Simula_r
2026-01-08 19:22:50 -08:00
committed by GitHub
parent 51a7654a39
commit 1bf5b5397d
6 changed files with 496 additions and 197 deletions

View File

@@ -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="'&rarr;'"></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)

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

View File

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

View File

@@ -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()
} }

View File

@@ -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 }),

View File

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