-
Notifications
You must be signed in to change notification settings - Fork 502
Feat(cloud)/new top up dialog #7899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1456d7e
8493fc7
be06fc0
ecac159
9d1b849
3ae7dd6
7359a27
b6f10ca
28e23d9
1225079
3b409c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,180 +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)]" | ||
simula-r marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| > | ||
simula-r marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <!-- 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> | ||
|
Comment on lines
+14
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Use common button component and add aria-label for accessibility. The close button is implemented as a plain Additionally, icon-only buttons require an ♻️ Proposed refactor using IconButtonImport IconButton at the top of the script: +import IconButton from '@/components/ui/button/IconButton.vue'Then replace the button in the template: - <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>
+ <IconButton
+ icon="lucide--x"
+ variant="muted-textonly"
+ size="icon-sm"
+ :aria-label="$t('g.close')"
+ @click="handleClose"
+ />Based on learnings and coding guidelines.
🤖 Prompt for AI Agents |
||
| </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="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" | ||
|
|
||
| <!-- 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" | ||
| > | ||
| {{ t('subscription.videoTemplateBasedCredits') }} | ||
| </span> | ||
| <template #prefix> | ||
| <i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" /> | ||
| </template> | ||
| </FormattedNumberStepper> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Buy Button --> | ||
| <!-- 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 | ||
| > | ||
|
Comment on lines
+117
to
+122
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find and examine the useExternalLink composable
fd -t f "useExternalLink" src/Repository: Comfy-Org/ComfyUI_frontend Length of output: 144 🏁 Script executed: #!/bin/bash
# Examine the useExternalLink composable
cat -n src/composables/useExternalLink.tsRepository: Comfy-Org/ComfyUI_frontend Length of output: 3958 🏁 Script executed: #!/bin/bash
# Also check the component file around the mentioned lines
sed -n '110,125p' src/components/dialog/content/TopUpCreditsDialogContent.vueRepository: Comfy-Org/ComfyUI_frontend Length of output: 485 Consider centralizing the enterprise link URL in the external link composable. The enterprise contact URL is hardcoded. To maintain consistency with other external links in the component and the codebase, add this URL to the 🤖 Prompt for AI Agents |
||
| </p> | ||
|
|
||
| <div class="pt-8 pb-8 flex flex-col gap-8 px-8"> | ||
| <Button | ||
| :disabled="!selectedCredits || loading" | ||
| :disabled="!isValidAmount || loading" | ||
| :loading="loading" | ||
| variant="primary" | ||
| :class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')" | ||
| size="lg" | ||
| class="h-10 justify-center" | ||
| @click="handleBuy" | ||
| > | ||
| {{ $t('credits.topUp.buy') }} | ||
| {{ $t('credits.topUp.buyCredits') }} | ||
| </Button> | ||
| </div> | ||
| <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> | ||
| <div class="flex items-center justify-center gap-1"> | ||
| <a | ||
| href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera" | ||
| :href="pricingUrl" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1" | ||
| class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground" | ||
| > | ||
| <span class="underline"> | ||
| {{ t('subscription.videoEstimateTryTemplate') }} | ||
| </span> | ||
| <span class="no-underline" v-html="'→'"></span> | ||
| {{ $t('credits.topUp.viewPricing') }} | ||
| <i class="icon-[lucide--external-link] size-4" /> | ||
| </a> | ||
| </div> | ||
| </Popover> | ||
| </div> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { Popover } from 'primevue' | ||
| 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 | ||
simula-r marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // State | ||
| const selectedPreset = ref<number | null>(50) | ||
| const payAmount = ref(50) | ||
| const showCeilingWarning = ref(false) | ||
| const loading = ref(false) | ||
|
|
||
| const popover = ref() | ||
| // 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 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)) | ||
| } | ||
|
|
||
| const togglePopover = (event: Event) => { | ||
| popover.value.toggle(event) | ||
| // Event handlers | ||
| function handlePayAmountChange(value: number) { | ||
| payAmount.value = value | ||
| selectedPreset.value = null | ||
| showCeilingWarning.value = false | ||
| } | ||
|
|
||
| const creditOptions: CreditOption[] = [ | ||
| { | ||
| credits: 1055, // $5.00 | ||
| description: t('credits.topUp.videosEstimate', { count: 30 }) | ||
| }, | ||
| { | ||
| 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 }) | ||
| 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' }) | ||
| } | ||
|
|
||
| const handleBuy = async () => { | ||
| if (!selectedCredits.value) return | ||
| 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) | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.